diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index 57ea50d..a1ddf12 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -33,6 +33,93 @@ ImVec2 begin_panel(const char* id, ImVec2 size, ImVec2 panel) { return panel; } +ImVec2 begin_page(const char* id, ImVec2 size) { + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(size); + if (!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) ImGui::SetNextWindowFocus(); + // Opaque, and darker than a tile: a page covers the window, so the main + // screen must not show through, and its tiles need something to sit on. + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::ColorConvertU32ToFloat4(rgba(14, 15, 18))); + ImGui::Begin(id, nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); + ImGui::PopStyleColor(); + return size; +} + +bool tile_hit(const char* id, ImVec2 position, ImVec2 size, bool enabled, bool& hovered) { + ImGui::SetCursorScreenPos(position); + ImGui::BeginDisabled(!enabled); + const bool pressed = ImGui::InvisibleButton(id, size); + hovered = enabled && ImGui::IsItemHovered(); + ImGui::EndDisabled(); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + return pressed; +} + +bool nav_tile(const Fonts& fonts, float width, const char* label, bool selected, const std::string& count, + bool accent, const std::string& tip) { + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 position = ImGui::GetCursorScreenPos(); + const ImVec2 size(width, S(54)); + bool hovered{}; + const bool pressed = tile_hit(label, position, size, true, hovered); + const ImVec2 end(position.x + size.x, position.y + size.y); + // Each tile gets its own hand-cut edge, so no two wobble the same way. + unsigned seed = 7; + for (const char* letter = label; *letter; ++letter) seed = seed * 31u + static_cast(*letter); + skate_theme::rough_rect(draw, position, end, + selected ? color::blue : hovered ? color::tile_grey : color::tile, seed, g_scale); + draw->AddText(fonts.heading, fonts.heading->FontSize, + ImVec2(position.x + S(16), position.y + (size.y - fonts.heading->FontSize) * 0.5f), + selected ? color::ink : color::text, label); + if (!count.empty()) { + const float height = fonts.caption->FontSize + S(8); + badge(draw, fonts, ImVec2(end.x - S(14) - badge_width(fonts, count), position.y + (size.y - height) * 0.5f), + count, selected ? rgba(0, 0, 0, 0.32f) : accent ? color::blue : rgba(255, 255, 255, 0.14f), + !selected && accent ? color::ink : color::text); + } + if (hovered && !tip.empty()) ImGui::SetTooltip("%s", tip.c_str()); + ImGui::SetCursorScreenPos(ImVec2(position.x, end.y + S(8))); + return pressed; +} + +float badge_width(const Fonts& fonts, const std::string& text) { + return fonts.caption->CalcTextSizeA(fonts.caption->FontSize, FLT_MAX, 0, text.c_str()).x + S(18); +} + +void badge(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const std::string& text, ImU32 fill, ImU32 ink) { + const float height = fonts.caption->FontSize + S(8); + const ImVec2 end(position.x + badge_width(fonts, text), position.y + height); + draw->AddRectFilled(position, end, fill, height * 0.5f); + draw->AddText(fonts.caption, fonts.caption->FontSize, ImVec2(position.x + S(9), position.y + S(4)), ink, text.c_str()); +} + +void field(const Fonts& fonts, const char* name, const std::string& value) { + if (value.empty()) return; + ImGui::PushFont(fonts.caption); + ImGui::TextDisabled("%s", name); + ImGui::PopFont(); + ImGui::PushTextWrapPos(0); + ImGui::TextUnformatted(value.c_str()); + ImGui::PopTextWrapPos(); +} + +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. + 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))); + 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)); + return pressed; +} + void open_path(const fs::path& path) { std::error_code error; fs::create_directories(path, error); @@ -167,6 +254,7 @@ void apply_style() { colours[ImGuiCol_HeaderHovered] = rgb(rgba(1, 131, 255, 0.35f)); colours[ImGuiCol_HeaderActive] = rgb(rgba(1, 131, 255, 0.5f)); colours[ImGuiCol_PopupBg] = rgb(rgba(26, 26, 26)); + colours[ImGuiCol_ModalWindowDimBg] = rgb(rgba(4, 6, 9, 0.72f)); colours[ImGuiCol_Separator] = rgb(color::outline); colours[ImGuiCol_TextSelectedBg] = rgb(rgba(1, 131, 255, 0.45f)); colours[ImGuiCol_NavHighlight] = ImVec4(0, 0, 0, 0); @@ -199,7 +287,17 @@ int run(const launcher_app::Session& session, const std::vector& a using namespace detail; const bool com = SUCCEEDED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED)); SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + RECT work{}; + SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0); g_scale = std::max(1.0f, static_cast(GetDpiForSystem()) / 96.0f); + // The window cannot be resized, so the design size has to fit the screen: + // scale down for a small laptop, or a high DPI that would push the window + // past the taskbar. Fonts and the style are built from the final scale. + const auto work_width = static_cast(work.right - work.left); + const auto work_height = static_cast(work.bottom - work.top); + if (work_width > 0 && work_height > 0) + g_scale = std::clamp(std::min(work_width * 0.98f / design_width, work_height * 0.96f / design_height), + 0.62f, g_scale); const auto instance = GetModuleHandleW(nullptr); WNDCLASSEXW window_class{sizeof(window_class)}; @@ -213,8 +311,6 @@ int run(const launcher_app::Session& session, const std::vector& a window_class.lpszClassName = L"ReSkateLauncher"; RegisterClassExW(&window_class); - RECT work{}; - SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0); const int width = static_cast(S(design_width)); const int height = static_cast(S(design_height)); const HWND window = CreateWindowExW(WS_EX_APPWINDOW, window_class.lpszClassName, L"ReSkate", diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index d5909d1..9057ec6 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -1,430 +1,482 @@ -#include "gui_internal.h" - -#include "mod_manager.h" - -#include -#include - -// The main screen: background, name plate, tiles and the STATUS tile. -namespace dingosdk::launcher_gui::detail { -namespace { - -void draw_background(ImDrawList* draw, ImVec2 size, float time) { - // Vertical gradient plus a warm glow bottom-right and a cool glow top-left, - // evaluated on a grid of bilinear quads. The quads are opaque: stacked - // circles band, and translucent layers under the modal dim left holes on - // an RX 9070 XT. - struct Glow { ImVec2 centre; float radius; ImVec4 colour; }; - const Glow glows[]{ - {ImVec2(size.x * 0.95f, size.y * 1.05f), S(700), ImVec4(1.0f, 0.5f, 0.08f, 0.42f)}, - {ImVec2(size.x * 0.02f, size.y * -0.08f), S(560), ImVec4(0.23f, 0.38f, 0.9f, 0.22f)}, - }; - const auto top = ImGui::ColorConvertU32ToFloat4(color::background_top); - const auto bottom = ImGui::ColorConvertU32ToFloat4(color::background_bottom); - const auto field = [&](float x, float y) { - const float v = std::clamp(y / size.y, 0.0f, 1.0f); - ImVec4 out(top.x + (bottom.x - top.x) * v, top.y + (bottom.y - top.y) * v, top.z + (bottom.z - top.z) * v, 1); - for (const auto& glow : glows) { - const float t = std::min(std::hypot(x - glow.centre.x, y - glow.centre.y) / glow.radius, 1.0f); - const float a = glow.colour.w * (1 - t) * (1 - t); - out.x += (glow.colour.x - out.x) * a; - out.y += (glow.colour.y - out.y) * a; - out.z += (glow.colour.z - out.z) * a; - } - return ImGui::ColorConvertFloat4ToU32(out); - }; - constexpr int columns = 32, rows = 18; - const float cell_x = size.x / columns, cell_y = size.y / rows; - for (int row = 0; row < rows; ++row) - for (int column = 0; column < columns; ++column) { - const float x0 = cell_x * static_cast(column), y0 = cell_y * static_cast(row); - const float x1 = x0 + cell_x, y1 = y0 + cell_y; - draw->AddRectFilledMultiColor(ImVec2(x0, y0), ImVec2(x1, y1), - field(x0, y0), field(x1, y0), field(x1, y1), field(x0, y1)); - } - - // Skatepark floor: a perspective grid converging on the wordmark. - const ImVec2 vanish(size.x * 0.5f, size.y * 0.46f); - const float floor = size.y * 0.62f; - for (int line = -14; line <= 14; ++line) { - const float x = size.x * 0.5f + static_cast(line) * S(120); - draw->AddLine(ImVec2(vanish.x + (x - vanish.x) * 0.18f, floor), ImVec2(x, size.y), rgba(255, 255, 255, 0.035f), 1); - } - const float scroll = std::fmod(time * 0.06f, 1.0f); - for (int row = 0; row < 9; ++row) { - const float t = (static_cast(row) + scroll) / 9.0f; - const float y = floor + (size.y - floor) * t * t; - draw->AddLine(ImVec2(0, y), ImVec2(size.x, y), rgba(255, 255, 255, 0.03f * t + 0.01f), 1); - } - - // Drifting outline shapes, like chalk marks on a ramp. - for (int index = 0; index < 28; ++index) { - const float seed = static_cast(index) * 12.9898f; - const float fx = std::fmod(std::abs(std::sin(seed) * 43758.5453f), 1.0f); - const float fy = std::fmod(std::abs(std::sin(seed * 1.7f) * 24634.6345f), 1.0f); - const float drift = time * (0.004f + 0.006f * fx); - const ImVec2 centre(size.x * std::fmod(fx + drift, 1.0f), size.y * fy + std::sin(time * 0.3f + seed) * S(6)); - const float radius = S(3.0f + 16.0f * fy * fx); - const float alpha = 0.05f + 0.07f * fy; - if (index % 3 == 0) { - const float angle = time * 0.2f + seed; - ImVec2 corners[4]; - for (int corner = 0; corner < 4; ++corner) { - const float a = angle + static_cast(corner) * 1.5707963f; - corners[corner] = ImVec2(centre.x + std::cos(a) * radius, centre.y + std::sin(a) * radius); - } - draw->AddQuad(corners[0], corners[1], corners[2], corners[3], rgba(255, 255, 255, alpha), 1.2f); - } else { - draw->AddCircle(centre, radius, rgba(255, 255, 255, alpha), 0, 1.2f); - } - } - // Vignette edges. - draw->AddRectFilledMultiColor(ImVec2(0, size.y * 0.75f), size, 0, 0, rgba(0, 0, 0, 0.45f), rgba(0, 0, 0, 0.45f)); -} - -// A tile's icon centred at `centre`; false when it isn't loaded (use the drawn one). -bool tile_icon(ImDrawList* draw, const Background& icon, ImVec2 centre, float size, bool hovered) { - if (!icon.id) return false; - const float half = size * 0.5f; - draw->AddImage(icon.id, ImVec2(centre.x - half, centre.y - half), ImVec2(centre.x + half, centre.y + half), - ImVec2(0, 0), ImVec2(1, 1), rgba(255, 255, 255, hovered ? 0.6f : 0.32f)); - return true; -} - -void draw_photo(ImDrawList* draw, ImVec2 size, float time) { - // Cover-fit with a slow drift so the still image feels alive. - const float zoom = 1.04f + 0.02f * std::sin(time * 0.05f); - const float scale = std::max(size.x / g_background.width, size.y / g_background.height) * zoom; - const ImVec2 image(g_background.width * scale, g_background.height * scale); - const ImVec2 origin((size.x - image.x) * 0.5f + std::sin(time * 0.037f) * S(10), - (size.y - image.y) * 0.5f + std::cos(time * 0.029f) * S(6)); - draw->AddImage(g_background.id, origin, ImVec2(origin.x + image.x, origin.y + image.y)); - // Keep the wordmark and bottom row readable over a busy photo. - draw->AddRectFilled(ImVec2(0, 0), size, rgba(6, 8, 12, 0.38f)); - draw->AddRectFilledMultiColor(ImVec2(0, size.y * 0.45f), size, rgba(6, 8, 12, 0), rgba(6, 8, 12, 0), - rgba(6, 8, 12, 0.92f), rgba(6, 8, 12, 0.92f)); - draw->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(size.x, S(90)), rgba(6, 8, 12, 0.6f), rgba(6, 8, 12, 0.6f), - rgba(6, 8, 12, 0), rgba(6, 8, 12, 0)); -} - -// Drawing shared with the in-game menu (UI/skate_theme.h), at the launcher's scale. -using skate_theme::Icon; -using skate_theme::rotate_since; -void rough_rect(ImDrawList* draw, ImVec2 a, ImVec2 b, ImU32 colour, unsigned seed) { - skate_theme::rough_rect(draw, a, b, colour, seed, g_scale); -} -void scribble(ImDrawList* draw, ImVec2 a, ImVec2 b, unsigned seed) { skate_theme::scribble(draw, a, b, seed, g_scale); } -void progress_bar(ImDrawList* draw, ImVec2 a, ImVec2 b, float fill, float time) { - skate_theme::striped_bar(draw, a, b, fill, time, g_scale); -} - -// Brushed page title, tilted like the HUB's. -void draw_title(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const char* text) { - const int start = draw->VtxBuffer.Size; - const float size = fonts.title->FontSize; - const auto extent = fonts.title->CalcTextSizeA(size, FLT_MAX, 0, text); - draw->AddText(fonts.title, size, ImVec2(position.x + S(3), position.y + S(4)), rgba(0, 0, 0, 0.5f), text); - draw->AddText(fonts.title, size, position, color::text, text); - rotate_since(draw, start, -4.0f, ImVec2(position.x + extent.x * 0.5f, position.y + extent.y * 0.5f)); -} - -// Name plate in the top-left corner, like the HUB's player card. -void draw_plate(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const std::string& title, const std::string& version) { - const float h = S(46); - const float text = std::max(fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, 0, title.c_str()).x, - fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, version.c_str()).x); - const float width = std::max(S(300), h + S(28) + text); - draw->AddRectFilled(ImVec2(position.x + h, position.y + S(3)), ImVec2(position.x + width, position.y + h - S(3)), - rgba(44, 47, 49, 0.94f)); - draw->AddRectFilled(position, ImVec2(position.x + h, position.y + h), color::avatar); - // A small deck on the avatar tile. - const ImVec2 c(position.x + h * 0.5f, position.y + h * 0.5f); - const int start = draw->VtxBuffer.Size; - draw->AddRectFilled(ImVec2(c.x - S(15), c.y - S(5.5f)), ImVec2(c.x + S(15), c.y + S(5.5f)), color::text, S(5.5f)); - for (const float x : {-9.0f, 9.0f}) draw->AddCircleFilled(ImVec2(c.x + S(x), c.y), S(1.8f), color::avatar); - rotate_since(draw, start, -40.0f, c); - draw->AddCircleFilled(ImVec2(position.x + S(3), position.y + S(3)), S(5), color::good); - draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(position.x + h + S(12), position.y + S(5)), color::text, title.c_str()); - draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(position.x + h + S(12), position.y + S(24)), color::muted, version.c_str()); -} - -bool hit(const char* id, ImVec2 position, ImVec2 size, bool enabled, bool& hovered) { - ImGui::SetCursorScreenPos(position); - ImGui::BeginDisabled(!enabled); - const bool pressed = ImGui::InvisibleButton(id, size); - hovered = enabled && ImGui::IsItemHovered(); - ImGui::EndDisabled(); - if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - return pressed; -} - -// The big action tile: blue like skate.'s selected tile, grey and scribbled when locked. -bool action_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 size, const char* label, - const std::string& detail, bool enabled, bool secondary) { - bool hovered{}; - const bool pressed = hit("##primary", position, size, enabled, hovered); - const ImVec2 end(position.x + size.x, position.y + size.y); - const bool blue = enabled && !secondary; - rough_rect(draw, position, end, blue ? color::blue : enabled ? color::tile : color::tile_grey, 7); - if (!enabled) scribble(draw, position, end, 7); - if (hovered) draw->AddRect(ImVec2(position.x - S(3), position.y - S(3)), ImVec2(end.x + S(3), end.y + S(3)), color::text, 0, 0, S(3)); - const ImU32 ink = blue ? color::ink : enabled ? color::text : rgba(160, 162, 168); - draw->AddText(fonts.action, fonts.action->FontSize, ImVec2(position.x + S(18), position.y + S(12)), ink, label); - if (!detail.empty()) - draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(position.x + S(20), end.y - S(20) - fonts.bold->FontSize), - blue ? rgba(0, 0, 0, 0.72f) : color::muted, detail.c_str(), nullptr, size.x - S(40)); - return pressed; -} - -bool settings_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 size, bool enabled) { - bool hovered{}; - const bool pressed = hit("##settings", position, size, enabled, hovered); - const ImVec2 end(position.x + size.x, position.y + size.y); - rough_rect(draw, position, end, color::tile, 3); - if (hovered) draw->AddRect(ImVec2(position.x - S(3), position.y - S(3)), ImVec2(end.x + S(3), end.y + S(3)), color::text, 0, 0, S(3)); - draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(position.x + S(18), position.y + (size.y - fonts.tile->FontSize) * 0.5f), - color::text, "SETTINGS"); - // Large faded wheel in the corner, like the icons on the HUB's tiles. - const ImVec2 centre(end.x - S(44), position.y + size.y * 0.5f); - if (tile_icon(draw, g_icon_settings, centre, S(58), hovered)) return pressed; - const ImU32 ink = rgba(255, 255, 255, hovered ? 0.55f : 0.3f); - for (int tooth = 0; tooth < 8; ++tooth) { - const float angle = static_cast(tooth) * 0.7853982f; - draw->AddLine(ImVec2(centre.x + std::cos(angle) * S(14), centre.y + std::sin(angle) * S(14)), - ImVec2(centre.x + std::cos(angle) * S(22), centre.y + std::sin(angle) * S(22)), ink, S(6)); - } - draw->AddCircle(centre, S(15), ink, 0, S(4)); - draw->AddCircle(centre, S(6), ink, 0, S(3)); - return pressed; -} - -void window_buttons(ImDrawList* draw, HWND window, ImVec2 size) { - const ImVec2 button(S(46), S(34)); - bool hovered{}; - ImVec2 position(size.x - button.x * 2, 0); - if (hit("##minimise", position, button, true, hovered)) ShowWindow(window, SW_MINIMIZE); - if (hovered) draw->AddRectFilled(position, ImVec2(position.x + button.x, button.y), rgba(255, 255, 255, 0.08f)); - const ImVec2 middle(position.x + button.x * 0.5f, button.y * 0.5f); - draw->AddLine(ImVec2(middle.x - S(5), middle.y), ImVec2(middle.x + S(5), middle.y), color::text, S(1)); - position.x += button.x; - if (hit("##close", position, button, true, hovered)) PostMessageW(window, WM_CLOSE, 0, 0); - if (hovered) draw->AddRectFilled(position, ImVec2(position.x + button.x, button.y), rgba(232, 17, 35)); - const ImVec2 cross(position.x + button.x * 0.5f, button.y * 0.5f); - draw->AddLine(ImVec2(cross.x - S(5), cross.y - S(5)), ImVec2(cross.x + S(5), cross.y + S(5)), color::text, S(1)); - draw->AddLine(ImVec2(cross.x - S(5), cross.y + S(5)), ImVec2(cross.x + S(5), cross.y - S(5)), color::text, S(1)); -} - -bool mods_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 size, bool enabled, const std::string& detail) { - bool hovered{}; - const bool pressed = hit("##mods", position, size, enabled, hovered); - const ImVec2 end(position.x + size.x, position.y + size.y); - rough_rect(draw, position, end, color::tile, 4); - if (hovered) draw->AddRect(ImVec2(position.x - S(3), position.y - S(3)), ImVec2(end.x + S(3), end.y + S(3)), color::text, 0, 0, S(3)); - const float text_y = position.y + (size.y - fonts.tile->FontSize) * 0.5f - (detail.empty() ? 0 : S(9)); - draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(position.x + S(18), text_y), color::text, "MODS"); - if (!detail.empty()) - draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(position.x + S(20), text_y + fonts.tile->FontSize + S(2)), - color::muted, detail.c_str()); - // The skate tool, faded like the wheel on SETTINGS. - const ImVec2 centre(end.x - S(44), position.y + size.y * 0.5f); - if (tile_icon(draw, g_icon_mods, centre, S(58), hovered)) return pressed; - const ImU32 ink = rgba(255, 255, 255, hovered ? 0.55f : 0.3f); - for (int layer = 0; layer < 3; ++layer) { - const float y = centre.y - S(12) + static_cast(layer) * S(12); - const ImVec2 points[]{ImVec2(centre.x, y - S(7)), ImVec2(centre.x + S(20), y), ImVec2(centre.x, y + S(7)), - ImVec2(centre.x - S(20), y)}; - draw->AddPolyline(points, 4, ink, ImDrawFlags_Closed, S(3)); - } - return pressed; -} - -// The STATUS tile, laid out like the HUB's BOUNTIES tile: a header and icon rows. -void status_tile(ImDrawList* draw, const Fonts& fonts, const State& state, ImVec2 position, float width, float time) { - struct Row { Icon icon; std::string text; std::string detail; }; - std::vector rows; - Icon now = Icon::busy; - switch (state.phase) { - case Phase::ready: now = Icon::check; break; - case Phase::failed: now = Icon::fail; break; - case Phase::update_available: - case Phase::game_missing: - case Phase::game_outdated: now = Icon::warning; break; - default: break; - } - rows.push_back({now, state.status, state.detail}); - if (state.config) { - const auto build = state.config->game.build_id.empty() ? state.config->game.manifest_id : state.config->game.build_id; - const bool game_ok = state.phase != Phase::game_missing && state.phase != Phase::game_outdated && - !(state.phase == Phase::downloading); - rows.push_back({game_ok ? Icon::check : Icon::warning, "skate. build " + build, {}}); - if (update::binary_updates_enabled()) - rows.push_back({state.phase == Phase::update_available ? Icon::warning : Icon::check, - "ReSkate " + state.config->runtime.version, {}}); - } - const float text_x = position.x + S(52), text_width = width - S(72); - float height = S(74); - for (const auto& row : rows) { - height += fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, text_width, row.text.c_str()).y + S(12); - if (!row.detail.empty()) - height += fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, text_width, row.detail.c_str()).y + S(4); - } - if (state.progress >= 0) height += S(28); - const ImVec2 end(position.x + width, position.y + height); - rough_rect(draw, position, end, color::tile, 5); - draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(position.x + S(18), position.y + S(16)), color::text, "STATUS"); - float y = position.y + S(66); - for (const auto& row : rows) { - skate_theme::status_icon(draw, ImVec2(position.x + S(30), y + fonts.bold->FontSize * 0.5f), row.icon, time, g_scale); - const auto text = fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, text_width, row.text.c_str()); - draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, y), - row.icon == Icon::fail ? color::danger : color::text, row.text.c_str(), nullptr, text_width); - y += text.y + S(4); - if (!row.detail.empty()) { - const auto detail = fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, text_width, row.detail.c_str()); - draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, y), color::muted, row.detail.c_str(), nullptr, text_width); - y += detail.y + S(4); - } - y += S(8); - } - if (state.progress >= 0) - progress_bar(draw, ImVec2(position.x + S(20), y + S(4)), ImVec2(end.x - S(20), y + S(16)), state.progress, time); -} - -} // namespace - -void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPanel& mods_panel) { - const auto& io = ImGui::GetIO(); - const ImVec2 size = io.DisplaySize; - const float time = static_cast(ImGui::GetTime()); - const auto state = launcher.snapshot(); - const bool qr_open = !state.qr.empty(); - // The Thunderstore listing loads in the background from the start, so the - // MODS tile can say when installed mods have updates. - collect_listing(mods_panel); - refresh_listing(mods_panel, time); - // A mod dropped on the window opens the Mods panel and installs it. - { - std::lock_guard lock(g_dropped_mutex); - if (!g_dropped.empty() && !ui.settings && !ui.sign_in && !qr_open && !state.prompt && !mods_panel.installing) { - ui.mods = true; - mods_panel.tab = 0; - if (!mods_panel.scanned) scan(mods_panel, launcher.session()); - start_install(mods_panel, g_dropped.front(), false); - } - g_dropped.clear(); - } - // The Steam name for the plate, and the one-time offline notice, both come - // from the first Steam check; steam_offline_seen keeps it to one decision. - if (time - ui.steam_checked > 5) { - ui.steam_name = launcher_app::steam_persona_name(); - ui.steam_checked = time; - if (!ui.steam_offline_seen) { - ui.steam_offline_seen = true; - ui.steam_offline = !launcher.settings().offline && !launcher_app::steam_signed_in(); - } - } - const bool modal = ui.settings || ui.mods || ui.sign_in || qr_open || state.prompt.has_value() || - ui.steam_offline; - - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(size); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0); - ImGui::Begin("##root", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoBackground | - ImGuiWindowFlags_NoScrollWithMouse); - ImGui::PopStyleVar(2); - auto* draw = ImGui::GetWindowDrawList(); - if (g_background.id) draw_photo(draw, size, time); - else draw_background(draw, size, time); - std::string version = "Development build"; - if (update::binary_updates_enabled() && state.config && !state.config->launcher.version.empty()) - version = "Launcher " + state.config->launcher.version; - const bool greet = !launcher.settings().offline && !ui.steam_name.empty(); - draw_plate(draw, fonts, ImVec2(S(50), S(40)), greet ? "Welcome, " + ui.steam_name : std::string("ReSkate"), version); - draw_title(draw, fonts, ImVec2(S(52), S(88)), "RESKATE"); - - window_buttons(draw, window, size); - - ImGui::BeginDisabled(modal); - const float column = S(340); - const ImVec2 primary_size(column, S(150)); - const ImVec2 primary(size.x - S(60) - column, size.y - S(50) - primary_size.y); - const ImVec2 settings(primary.x, primary.y - S(14) - S(84)); - const ImVec2 mods_position(primary.x, settings.y - S(14) - S(84)); - const char* label = "PLAY"; - std::string detail; - bool enabled = !launcher.busy(); - bool secondary = false; - const auto build = state.config && !state.config->game.build_id.empty() ? state.config->game.build_id : std::string(); - switch (state.phase) { - case Phase::checking: label = "CHECKING"; enabled = false; break; - case Phase::update_available: label = "UPDATE"; detail = "New ReSkate files are ready"; break; - case Phase::updating: label = "UPDATING"; enabled = false; break; - case Phase::game_missing: label = "INSTALL"; detail = "Download skate. from Steam"; break; - case Phase::game_outdated: label = "DOWNLOAD"; detail = "Get skate. build " + build; break; - case Phase::downloading: - label = "CANCEL"; enabled = true; secondary = true; - detail = state.progress >= 0 ? std::format("Downloading {:.0f}%", state.progress * 100) : "Downloading"; - break; - case Phase::ready: - label = "PLAY"; - // Release builds install the configured runtime before PLAY appears. - detail = !state.config ? std::string("Offline") - : !update::binary_updates_enabled() ? std::string("ReSkate development build") - : state.config->runtime.version.empty() ? std::string("ReSkate") - : "ReSkate " + state.config->runtime.version; - break; - case Phase::launching: label = "LAUNCHING"; enabled = false; break; - case Phase::failed: label = "RETRY"; break; - } - const auto act = [&] { - switch (state.phase) { - case Phase::update_available: launcher.apply_updates(); break; - case Phase::game_missing: - case Phase::game_outdated: open_sign_in(launcher, ui, false); break; - case Phase::downloading: launcher.cancel(); break; - case Phase::ready: launcher.play(); break; - case Phase::failed: launcher.check(); break; - default: break; - } - }; - if (action_tile(draw, fonts, primary, primary_size, label, detail, enabled && !modal, secondary)) act(); - if (settings_tile(draw, fonts, settings, ImVec2(column, S(84)), !modal)) ui.settings = true; - if (time - ui.mods_checked > 5 && !ui.mods) { - std::size_t enabled_mods = 0, installed = 0; - const auto list = mods::scan_mods(launcher_mods::mods_root(launcher.session().paths.directory).parent_path()); - for (const auto& entry : list.entries) { - ++installed; - if (entry.enabled) ++enabled_mods; - } - const auto pending = updates(mods_panel.store, installed_versions(list)).size(); - ui.mods_detail = pending ? (pending == 1 ? std::string("1 update available") : std::format("{} updates available", pending)) - : installed ? std::format("{} of {} enabled", enabled_mods, installed) - : std::string("Install and browse mods"); - ui.mods_checked = time; - } - if (mods_tile(draw, fonts, mods_position, ImVec2(column, S(84)), !modal, ui.mods_detail)) { - ui.mods = true; - mods_panel.scanned = false; - } - - if (enabled && !secondary && !modal && - (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) act(); - ImGui::EndDisabled(); - - status_tile(draw, fonts, state, ImVec2(S(60), S(200)), S(360), time); - - if (modal) draw->AddRectFilled(ImVec2(0, 0), size, rgba(4, 6, 9, 0.72f)); - g_drag_allowed = !modal && !ImGui::IsAnyItemHovered(); - ImGui::End(); - - if (state.prompt) prompt_window(launcher, fonts, size, *state.prompt, ui); - else if (qr_open) qr_window(launcher, fonts, size, state.qr); - else if (ui.sign_in) sign_in_window(launcher, fonts, size, ui); - else if (ui.settings) settings_window(launcher, fonts, size, ui); - else if (ui.mods) mods_window(launcher, fonts, size, ui, mods_panel, window); - else if (ui.steam_offline) steam_offline_window(fonts, size, ui); -} - -} // namespace dingosdk::launcher_gui::detail +#include "gui_internal.h" + +#include "mod_manager.h" +#include "problem.h" + +#include +#include + +// The main screen: background, name plate, tiles and the STATUS tile. +namespace dingosdk::launcher_gui::detail { +namespace { + +void draw_background(ImDrawList* draw, ImVec2 size, float time) { + // Vertical gradient plus a warm glow bottom-right and a cool glow top-left, + // evaluated on a grid of bilinear quads. The quads are opaque: stacked + // circles band, and translucent layers under the modal dim left holes on + // an RX 9070 XT. + struct Glow { ImVec2 centre; float radius; ImVec4 colour; }; + const Glow glows[]{ + {ImVec2(size.x * 0.95f, size.y * 1.05f), S(700), ImVec4(1.0f, 0.5f, 0.08f, 0.42f)}, + {ImVec2(size.x * 0.02f, size.y * -0.08f), S(560), ImVec4(0.23f, 0.38f, 0.9f, 0.22f)}, + }; + const auto top = ImGui::ColorConvertU32ToFloat4(color::background_top); + const auto bottom = ImGui::ColorConvertU32ToFloat4(color::background_bottom); + const auto field = [&](float x, float y) { + const float v = std::clamp(y / size.y, 0.0f, 1.0f); + ImVec4 out(top.x + (bottom.x - top.x) * v, top.y + (bottom.y - top.y) * v, top.z + (bottom.z - top.z) * v, 1); + for (const auto& glow : glows) { + const float t = std::min(std::hypot(x - glow.centre.x, y - glow.centre.y) / glow.radius, 1.0f); + const float a = glow.colour.w * (1 - t) * (1 - t); + out.x += (glow.colour.x - out.x) * a; + out.y += (glow.colour.y - out.y) * a; + out.z += (glow.colour.z - out.z) * a; + } + return ImGui::ColorConvertFloat4ToU32(out); + }; + constexpr int columns = 32, rows = 18; + const float cell_x = size.x / columns, cell_y = size.y / rows; + for (int row = 0; row < rows; ++row) + for (int column = 0; column < columns; ++column) { + const float x0 = cell_x * static_cast(column), y0 = cell_y * static_cast(row); + const float x1 = x0 + cell_x, y1 = y0 + cell_y; + draw->AddRectFilledMultiColor(ImVec2(x0, y0), ImVec2(x1, y1), + field(x0, y0), field(x1, y0), field(x1, y1), field(x0, y1)); + } + + // Skatepark floor: a perspective grid converging on the wordmark. + const ImVec2 vanish(size.x * 0.5f, size.y * 0.46f); + const float floor = size.y * 0.62f; + for (int line = -14; line <= 14; ++line) { + const float x = size.x * 0.5f + static_cast(line) * S(120); + draw->AddLine(ImVec2(vanish.x + (x - vanish.x) * 0.18f, floor), ImVec2(x, size.y), rgba(255, 255, 255, 0.035f), 1); + } + const float scroll = std::fmod(time * 0.06f, 1.0f); + for (int row = 0; row < 9; ++row) { + const float t = (static_cast(row) + scroll) / 9.0f; + const float y = floor + (size.y - floor) * t * t; + draw->AddLine(ImVec2(0, y), ImVec2(size.x, y), rgba(255, 255, 255, 0.03f * t + 0.01f), 1); + } + + // Drifting outline shapes, like chalk marks on a ramp. + for (int index = 0; index < 28; ++index) { + const float seed = static_cast(index) * 12.9898f; + const float fx = std::fmod(std::abs(std::sin(seed) * 43758.5453f), 1.0f); + const float fy = std::fmod(std::abs(std::sin(seed * 1.7f) * 24634.6345f), 1.0f); + const float drift = time * (0.004f + 0.006f * fx); + const ImVec2 centre(size.x * std::fmod(fx + drift, 1.0f), size.y * fy + std::sin(time * 0.3f + seed) * S(6)); + const float radius = S(3.0f + 16.0f * fy * fx); + const float alpha = 0.05f + 0.07f * fy; + if (index % 3 == 0) { + const float angle = time * 0.2f + seed; + ImVec2 corners[4]; + for (int corner = 0; corner < 4; ++corner) { + const float a = angle + static_cast(corner) * 1.5707963f; + corners[corner] = ImVec2(centre.x + std::cos(a) * radius, centre.y + std::sin(a) * radius); + } + draw->AddQuad(corners[0], corners[1], corners[2], corners[3], rgba(255, 255, 255, alpha), 1.2f); + } else { + draw->AddCircle(centre, radius, rgba(255, 255, 255, alpha), 0, 1.2f); + } + } + // Vignette edges. + draw->AddRectFilledMultiColor(ImVec2(0, size.y * 0.75f), size, 0, 0, rgba(0, 0, 0, 0.45f), rgba(0, 0, 0, 0.45f)); +} + +// A tile's icon centred at `centre`; false when it isn't loaded (use the drawn one). +bool tile_icon(ImDrawList* draw, const Background& icon, ImVec2 centre, float size, bool hovered) { + if (!icon.id) return false; + const float half = size * 0.5f; + draw->AddImage(icon.id, ImVec2(centre.x - half, centre.y - half), ImVec2(centre.x + half, centre.y + half), + ImVec2(0, 0), ImVec2(1, 1), rgba(255, 255, 255, hovered ? 0.6f : 0.32f)); + return true; +} + +void draw_photo(ImDrawList* draw, ImVec2 size, float time) { + // Cover-fit with a slow drift so the still image feels alive. + const float zoom = 1.04f + 0.02f * std::sin(time * 0.05f); + const float scale = std::max(size.x / g_background.width, size.y / g_background.height) * zoom; + const ImVec2 image(g_background.width * scale, g_background.height * scale); + const ImVec2 origin((size.x - image.x) * 0.5f + std::sin(time * 0.037f) * S(10), + (size.y - image.y) * 0.5f + std::cos(time * 0.029f) * S(6)); + draw->AddImage(g_background.id, origin, ImVec2(origin.x + image.x, origin.y + image.y)); + // Keep the wordmark and bottom row readable over a busy photo. + draw->AddRectFilled(ImVec2(0, 0), size, rgba(6, 8, 12, 0.38f)); + draw->AddRectFilledMultiColor(ImVec2(0, size.y * 0.45f), size, rgba(6, 8, 12, 0), rgba(6, 8, 12, 0), + rgba(6, 8, 12, 0.92f), rgba(6, 8, 12, 0.92f)); + draw->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(size.x, S(90)), rgba(6, 8, 12, 0.6f), rgba(6, 8, 12, 0.6f), + rgba(6, 8, 12, 0), rgba(6, 8, 12, 0)); +} + +// Drawing shared with the in-game menu (UI/skate_theme.h), at the launcher's scale. +using skate_theme::Icon; +using skate_theme::rotate_since; +void rough_rect(ImDrawList* draw, ImVec2 a, ImVec2 b, ImU32 colour, unsigned seed) { + skate_theme::rough_rect(draw, a, b, colour, seed, g_scale); +} +void scribble(ImDrawList* draw, ImVec2 a, ImVec2 b, unsigned seed) { skate_theme::scribble(draw, a, b, seed, g_scale); } +void progress_bar(ImDrawList* draw, ImVec2 a, ImVec2 b, float fill, float time) { + skate_theme::striped_bar(draw, a, b, fill, time, g_scale); +} + +// Name plate in the top-left corner, like the HUB's player card. +void draw_plate(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const std::string& title, const std::string& version) { + const float h = S(46); + const float text = std::max(fonts.bold->CalcTextSizeA(fonts.bold->FontSize, FLT_MAX, 0, title.c_str()).x, + fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, version.c_str()).x); + const float width = std::max(S(300), h + S(28) + text); + draw->AddRectFilled(ImVec2(position.x + h, position.y + S(3)), ImVec2(position.x + width, position.y + h - S(3)), + rgba(44, 47, 49, 0.94f)); + draw->AddRectFilled(position, ImVec2(position.x + h, position.y + h), color::avatar); + // A small deck on the avatar tile. + const ImVec2 c(position.x + h * 0.5f, position.y + h * 0.5f); + const int start = draw->VtxBuffer.Size; + draw->AddRectFilled(ImVec2(c.x - S(15), c.y - S(5.5f)), ImVec2(c.x + S(15), c.y + S(5.5f)), color::text, S(5.5f)); + for (const float x : {-9.0f, 9.0f}) draw->AddCircleFilled(ImVec2(c.x + S(x), c.y), S(1.8f), color::avatar); + rotate_since(draw, start, -40.0f, c); + draw->AddCircleFilled(ImVec2(position.x + S(3), position.y + S(3)), S(5), color::good); + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(position.x + h + S(12), position.y + S(5)), color::text, title.c_str()); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(position.x + h + S(12), position.y + S(24)), color::muted, version.c_str()); +} + +// The big action tile: blue like skate.'s selected tile, grey and scribbled when locked. +bool action_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 size, const char* label, + const std::string& detail, bool enabled, bool secondary) { + bool hovered{}; + const bool pressed = tile_hit("##primary", position, size, enabled, hovered); + const ImVec2 end(position.x + size.x, position.y + size.y); + const bool blue = enabled && !secondary; + rough_rect(draw, position, end, blue ? color::blue : enabled ? color::tile : color::tile_grey, 7); + if (!enabled) scribble(draw, position, end, 7); + if (hovered) draw->AddRect(ImVec2(position.x - S(3), position.y - S(3)), ImVec2(end.x + S(3), end.y + S(3)), color::text, 0, 0, S(3)); + const ImU32 ink = blue ? color::ink : enabled ? color::text : rgba(160, 162, 168); + draw->AddText(fonts.action, fonts.action->FontSize, ImVec2(position.x + S(18), position.y + S(12)), ink, label); + if (!detail.empty()) + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(position.x + S(20), end.y - S(20) - fonts.bold->FontSize), + blue ? rgba(0, 0, 0, 0.72f) : color::muted, detail.c_str(), nullptr, size.x - S(40)); + return pressed; +} + +bool settings_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 size, bool enabled) { + bool hovered{}; + const bool pressed = tile_hit("##settings", position, size, enabled, hovered); + const ImVec2 end(position.x + size.x, position.y + size.y); + rough_rect(draw, position, end, color::tile, 3); + if (hovered) draw->AddRect(ImVec2(position.x - S(3), position.y - S(3)), ImVec2(end.x + S(3), end.y + S(3)), color::text, 0, 0, S(3)); + draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(position.x + S(18), position.y + (size.y - fonts.tile->FontSize) * 0.5f), + color::text, "SETTINGS"); + // Large faded wheel in the corner, like the icons on the HUB's tiles. + const ImVec2 centre(end.x - S(44), position.y + size.y * 0.5f); + if (tile_icon(draw, g_icon_settings, centre, S(58), hovered)) return pressed; + const ImU32 ink = rgba(255, 255, 255, hovered ? 0.55f : 0.3f); + for (int tooth = 0; tooth < 8; ++tooth) { + const float angle = static_cast(tooth) * 0.7853982f; + draw->AddLine(ImVec2(centre.x + std::cos(angle) * S(14), centre.y + std::sin(angle) * S(14)), + ImVec2(centre.x + std::cos(angle) * S(22), centre.y + std::sin(angle) * S(22)), ink, S(6)); + } + draw->AddCircle(centre, S(15), ink, 0, S(4)); + draw->AddCircle(centre, S(6), ink, 0, S(3)); + return pressed; +} + +// The MODS tile, the biggest after PLAY: a mod browser nobody finds is no use. +bool mods_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 size, bool enabled, + const std::string& detail, std::size_t pending) { + bool hovered{}; + const bool pressed = tile_hit("##mods", position, size, enabled, hovered); + const ImVec2 end(position.x + size.x, position.y + size.y); + rough_rect(draw, position, end, color::tile, 4); + if (hovered) draw->AddRect(ImVec2(position.x - S(3), position.y - S(3)), ImVec2(end.x + S(3), end.y + S(3)), color::text, 0, 0, S(3)); + // Title and detail centred as one block, so the tile's height can change. + const float block = fonts.tile->FontSize + (detail.empty() ? 0 : fonts.body->FontSize + S(6)); + const float text_y = position.y + (size.y - block) * 0.5f; + draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(position.x + S(18), text_y), color::text, "MOD MANAGER"); + if (!detail.empty()) { + const float detail_y = text_y + fonts.tile->FontSize + S(6); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(position.x + S(20), detail_y), color::muted, + detail.c_str()); + if (pending) + badge(draw, fonts, + ImVec2(position.x + S(20) + fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, detail.c_str()).x + S(12), + detail_y + (fonts.body->FontSize - fonts.caption->FontSize - S(8)) * 0.5f), + pending == 1 ? std::string("1 UPDATE") : std::format("{} UPDATES", pending)); + } + // The skate tool, faded like the wheel on SETTINGS. + const ImVec2 centre(end.x - S(46), position.y + size.y * 0.5f); + if (tile_icon(draw, g_icon_mods, centre, S(56), hovered)) return pressed; + const ImU32 ink = rgba(255, 255, 255, hovered ? 0.55f : 0.3f); + for (int layer = 0; layer < 3; ++layer) { + const float y = centre.y - S(12) + static_cast(layer) * S(12); + const ImVec2 points[]{ImVec2(centre.x, y - S(7)), ImVec2(centre.x + S(20), y), ImVec2(centre.x, y + S(7)), + ImVec2(centre.x - S(20), y)}; + draw->AddPolyline(points, 4, ink, ImDrawFlags_Closed, S(3)); + } + return pressed; +} + +// The STATUS tile: what ReSkate is doing, or what went wrong and what to do +// about it. Laid out like the HUB's BOUNTIES tile, with the state in its edge. +void status_tile(const Fonts& fonts, const State& state, ImVec2 position, float width, float time, + const fs::path& logs, bool modal) { + auto* draw = ImGui::GetWindowDrawList(); + const bool failed = state.phase == Phase::failed; + const auto problem = failed ? launcher_problem::explain(state.status) : launcher_problem::Problem{}; + const std::string headline = failed ? problem.headline : state.status; + const std::string detail = failed ? problem.advice : state.detail; + + Icon icon = Icon::busy; + ImU32 accent = color::blue; + const char* pill = "WORKING"; + switch (state.phase) { + case Phase::ready: icon = Icon::check; accent = color::good; pill = "READY"; break; + case Phase::failed: icon = Icon::fail; accent = color::danger; pill = "PROBLEM"; break; + case Phase::update_available: + case Phase::game_missing: + case Phase::game_outdated: icon = Icon::warning; accent = color::warning; pill = "ACTION NEEDED"; break; + default: break; + } + + // The two things worth knowing at a glance, under the rule. + struct Fact { Icon icon; std::string text; }; + std::vector facts; + if (state.config) { + const auto build = state.config->game.build_id.empty() ? state.config->game.manifest_id + : state.config->game.build_id; + const bool game_ok = state.phase != Phase::game_missing && state.phase != Phase::game_outdated && + state.phase != Phase::downloading; + facts.push_back({game_ok ? Icon::check : Icon::warning, "skate. build " + build}); + if (update::binary_updates_enabled()) + facts.push_back({state.phase == Phase::update_available ? Icon::warning : Icon::check, + "ReSkate " + state.config->runtime.version}); + } + + const float pad = S(20), gutter = S(28); + const float text_x = position.x + pad + gutter; + const float text_width = width - pad * 2 - gutter; + const auto headline_size = fonts.heading->CalcTextSizeA(fonts.heading->FontSize, FLT_MAX, text_width, headline.c_str()); + const auto detail_size = detail.empty() ? ImVec2(0, 0) + : fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, text_width, detail.c_str()); + + float height = S(64) + headline_size.y; + if (!detail.empty()) height += S(8) + detail_size.y; + if (failed) height += S(12) + S(30); + if (state.progress >= 0) height += S(22); + if (!facts.empty()) height += S(18) + static_cast(facts.size()) * (fonts.body->FontSize + S(12)); + height += facts.empty() ? S(20) : S(8); + + const ImVec2 end(position.x + width, position.y + height); + rough_rect(draw, position, end, color::tile, 5); + // The state is readable before a word of it is: a coloured edge and a pill. + draw->AddRectFilled(position, ImVec2(position.x + S(4), end.y), accent); + draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(position.x + pad, position.y + S(14)), color::text, "STATUS"); + badge(draw, fonts, ImVec2(end.x - pad - badge_width(fonts, pill), + position.y + S(14) + (fonts.tile->FontSize - fonts.caption->FontSize - S(8)) * 0.5f), pill, accent, color::ink); + + float y = position.y + S(64); + skate_theme::status_icon(draw, ImVec2(position.x + pad + S(10), y + fonts.heading->FontSize * 0.5f), icon, time, g_scale); + draw->AddText(fonts.heading, fonts.heading->FontSize, ImVec2(text_x, y), failed ? color::danger : color::text, + headline.c_str(), nullptr, text_width); + y += headline_size.y; + if (!detail.empty()) { + y += S(8); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, y), color::muted, detail.c_str(), nullptr, + text_width); + y += detail_size.y; + } + if (failed) { + y += S(12); + ImGui::BeginDisabled(modal); + ImGui::SetCursorScreenPos(ImVec2(text_x, y)); + if (ImGui::Button("Open logs", ImVec2(S(104), S(30)))) open_path(logs); + ImGui::SameLine(0, S(8)); + if (ImGui::Button("Copy details", ImVec2(S(116), S(30)))) { + // The raw message, not the friendly one: this is for the Discord. + std::string report = "ReSkate launcher\n" + state.status + "\n"; + for (const auto& fact : facts) report += fact.text + "\n"; + ImGui::SetClipboardText(report.c_str()); + } + ImGui::EndDisabled(); + y += S(30); + } + if (state.progress >= 0) { + progress_bar(draw, ImVec2(position.x + pad, y + S(6)), ImVec2(end.x - pad, y + S(18)), state.progress, time); + y += S(22); + } + if (!facts.empty()) { + y += S(10); + draw->AddLine(ImVec2(position.x + pad, y), ImVec2(end.x - pad, y), rgba(255, 255, 255, 0.09f), S(1)); + y += S(8); + for (const auto& fact : facts) { + skate_theme::status_icon(draw, ImVec2(position.x + pad + S(10), y + fonts.body->FontSize * 0.5f), + fact.icon, time, g_scale); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, y), color::muted, fact.text.c_str()); + y += fonts.body->FontSize + S(12); + } + } +} + +} // namespace + +void page_title(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const char* text, float wanted) { + const int start = draw->VtxBuffer.Size; + const float size = wanted > 0 ? wanted : fonts.title->FontSize; + const auto extent = fonts.title->CalcTextSizeA(size, FLT_MAX, 0, text); + draw->AddText(fonts.title, size, ImVec2(position.x + S(3), position.y + S(4)), rgba(0, 0, 0, 0.5f), text); + draw->AddText(fonts.title, size, position, color::text, text); + skate_theme::rotate_since(draw, start, -4.0f, ImVec2(position.x + extent.x * 0.5f, position.y + extent.y * 0.5f)); +} + +void window_buttons(ImDrawList* draw, HWND window, ImVec2 size) { + const ImVec2 button(S(46), S(34)); + bool hovered{}; + ImVec2 position(size.x - button.x * 2, 0); + if (tile_hit("##minimise", position, button, true, hovered)) ShowWindow(window, SW_MINIMIZE); + if (hovered) draw->AddRectFilled(position, ImVec2(position.x + button.x, button.y), rgba(255, 255, 255, 0.08f)); + const ImVec2 middle(position.x + button.x * 0.5f, button.y * 0.5f); + draw->AddLine(ImVec2(middle.x - S(5), middle.y), ImVec2(middle.x + S(5), middle.y), color::text, S(1)); + position.x += button.x; + if (tile_hit("##close", position, button, true, hovered)) PostMessageW(window, WM_CLOSE, 0, 0); + if (hovered) draw->AddRectFilled(position, ImVec2(position.x + button.x, button.y), rgba(232, 17, 35)); + const ImVec2 cross(position.x + button.x * 0.5f, button.y * 0.5f); + draw->AddLine(ImVec2(cross.x - S(5), cross.y - S(5)), ImVec2(cross.x + S(5), cross.y + S(5)), color::text, S(1)); + draw->AddLine(ImVec2(cross.x - S(5), cross.y + S(5)), ImVec2(cross.x + S(5), cross.y - S(5)), color::text, S(1)); +} + +void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPanel& mods_panel) { + const auto& io = ImGui::GetIO(); + const ImVec2 size = io.DisplaySize; + const float time = static_cast(ImGui::GetTime()); + const auto state = launcher.snapshot(); + const bool qr_open = !state.qr.empty(); + // The Thunderstore listing loads in the background from the start, so the + // MODS tile can say when installed mods have updates. + collect_listing(mods_panel); + refresh_listing(mods_panel, time); + // A mod dropped on the window opens the Mods panel and installs it. + { + std::lock_guard lock(g_dropped_mutex); + if (!g_dropped.empty() && !ui.settings && !ui.sign_in && !qr_open && !state.prompt && !mods_panel.installing) { + ui.mods = true; + mods_panel.tab = 0; + if (!mods_panel.scanned) scan(mods_panel, launcher.session()); + start_install(mods_panel, g_dropped.front(), false); + } + g_dropped.clear(); + } + // The Steam name for the plate, and the one-time offline notice, both come + // from the first Steam check; steam_offline_seen keeps it to one decision. + if (time - ui.steam_checked > 5) { + ui.steam_name = launcher_app::steam_persona_name(); + ui.steam_checked = time; + if (!ui.steam_offline_seen) { + ui.steam_offline_seen = true; + ui.steam_offline = !launcher.settings().offline && !launcher_app::steam_signed_in(); + } + } + const bool modal = ui.settings || ui.mods || ui.sign_in || qr_open || state.prompt.has_value() || + ui.steam_offline; + + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(size); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0); + ImGui::Begin("##root", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(2); + auto* draw = ImGui::GetWindowDrawList(); + if (g_background.id) draw_photo(draw, size, time); + else draw_background(draw, size, time); + std::string version = "Development build"; + if (update::binary_updates_enabled() && state.config && !state.config->launcher.version.empty()) + version = "Launcher " + state.config->launcher.version; + const bool greet = !launcher.settings().offline && !ui.steam_name.empty(); + draw_plate(draw, fonts, ImVec2(S(50), S(40)), greet ? "Welcome, " + ui.steam_name : std::string("ReSkate"), version); + page_title(draw, fonts, ImVec2(S(52), S(88)), "RESKATE"); + + window_buttons(draw, window, size); + + ImGui::BeginDisabled(modal); + const float column = S(360); + const ImVec2 primary_size(column, S(160)); + const ImVec2 primary(size.x - S(64) - column, size.y - S(56) - primary_size.y); + // MODS sits directly under PLAY, where the eye already is, and is taller + // than SETTINGS so it reads as a place to go rather than a toggle. + const ImVec2 mods_size(column, S(116)); + const ImVec2 mods_position(primary.x, primary.y - S(14) - mods_size.y); + const ImVec2 settings(primary.x, mods_position.y - S(14) - S(84)); + const char* label = "PLAY"; + std::string detail; + bool enabled = !launcher.busy(); + bool secondary = false; + const auto build = state.config && !state.config->game.build_id.empty() ? state.config->game.build_id : std::string(); + switch (state.phase) { + case Phase::checking: label = "CHECKING"; enabled = false; break; + case Phase::update_available: label = "UPDATE"; detail = "New ReSkate files are ready"; break; + case Phase::updating: label = "UPDATING"; enabled = false; break; + case Phase::game_missing: label = "INSTALL"; detail = "Download skate. from Steam"; break; + case Phase::game_outdated: label = "DOWNLOAD"; detail = "Get skate. build " + build; break; + case Phase::downloading: + label = "CANCEL"; enabled = true; secondary = true; + detail = state.progress >= 0 ? std::format("Downloading {:.0f}%", state.progress * 100) : "Downloading"; + break; + case Phase::ready: + label = "PLAY"; + // Release builds install the configured runtime before PLAY appears. + detail = !state.config ? std::string("Offline") + : !update::binary_updates_enabled() ? std::string("ReSkate development build") + : state.config->runtime.version.empty() ? std::string("ReSkate") + : "ReSkate " + state.config->runtime.version; + break; + case Phase::launching: label = "LAUNCHING"; enabled = false; break; + case Phase::failed: label = "RETRY"; break; + } + const auto act = [&] { + switch (state.phase) { + case Phase::update_available: launcher.apply_updates(); break; + case Phase::game_missing: + case Phase::game_outdated: open_sign_in(launcher, ui, false); break; + case Phase::downloading: launcher.cancel(); break; + case Phase::ready: launcher.play(); break; + case Phase::failed: launcher.check(); break; + default: break; + } + }; + if (action_tile(draw, fonts, primary, primary_size, label, detail, enabled && !modal, secondary)) act(); + if (settings_tile(draw, fonts, settings, ImVec2(column, S(84)), !modal)) ui.settings = true; + if (time - ui.mods_checked > 5 && !ui.mods) { + std::size_t enabled_mods = 0, installed = 0; + const auto list = mods::scan_mods(launcher_mods::mods_root(launcher.session().paths.directory).parent_path()); + for (const auto& entry : list.entries) { + ++installed; + if (entry.enabled) ++enabled_mods; + } + ui.mods_updates = updates(mods_panel.store, installed_versions(list)).size(); + ui.mods_detail = installed ? std::format("{} of {} enabled", enabled_mods, installed) + : std::string("Browse and install from Thunderstore"); + ui.mods_checked = time; + } + if (mods_tile(draw, fonts, mods_position, mods_size, !modal, ui.mods_detail, ui.mods_updates)) { + ui.mods = true; + mods_panel.scanned = false; + } + + if (enabled && !secondary && !modal && + (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) act(); + ImGui::EndDisabled(); + + status_tile(fonts, state, ImVec2(S(60), S(200)), S(430), time, launcher.session().paths.logs, modal); + + if (modal) draw->AddRectFilled(ImVec2(0, 0), size, rgba(4, 6, 9, 0.72f)); + g_drag_allowed = !modal && !ImGui::IsAnyItemHovered(); + ImGui::End(); + + if (state.prompt) prompt_window(launcher, fonts, size, *state.prompt, ui); + else if (qr_open) qr_window(launcher, fonts, size, state.qr); + else if (ui.sign_in) sign_in_window(launcher, fonts, size, ui); + else if (ui.settings) settings_window(launcher, fonts, size, ui, window); + else if (ui.mods) mods_window(launcher, fonts, size, ui, mods_panel, window); + else if (ui.steam_offline) steam_offline_window(fonts, size, ui); +} + +} // namespace dingosdk::launcher_gui::detail diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 1908ff0..c487a8d 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -34,8 +34,11 @@ namespace update = launcher_update; // ---------------------------------------------------------------- look -constexpr float design_width = 1180.0f; -constexpr float design_height = 680.0f; +// The window's size at scale 1. Everything is laid out in these units and +// multiplied by g_scale, which gui.cpp lowers when the design size would not +// fit the screen's work area. +constexpr float design_width = 1440.0f; +constexpr float design_height = 840.0f; inline ImU32 rgba(int r, int g, int b, float a = 1.0f) { return IM_COL32(r, g, b, static_cast(std::clamp(a, 0.0f, 1.0f) * 255.0f)); @@ -92,8 +95,54 @@ class Renderer; inline Renderer* g_renderer{}; void panel_title(const Fonts& fonts, const char* text); +// Brushed title, tilted like the HUB's; `position` is in screen space, and +// `size` zero means the title font's own. Newlines start a new line. +void page_title(ImDrawList* draw, const Fonts& fonts, ImVec2 position, const char* text, float size = 0); // A centred modal panel; returns its size. ImVec2 begin_panel(const char* id, ImVec2 size, ImVec2 panel); +// A page filling the whole window, for screens that outgrew a modal panel +// (Mods). Returns its size, so it lays out like a panel. +ImVec2 begin_page(const char* id, ImVec2 size); +// Minimise and close in the top-right corner. A page draws its own: it covers +// the main screen's. +void window_buttons(ImDrawList* draw, HWND window, ImVec2 size); +// An invisible button at an absolute screen position, for a tile drawn by hand. +bool tile_hit(const char* id, ImVec2 position, ImVec2 size, bool enabled, bool& hovered); +// A tile in a page's nav rail, blue while its page is open, like skate.'s +// selected tile. Leaves the cursor where the next tile goes. +bool nav_tile(const Fonts& fonts, float width, const char* label, bool selected, const std::string& count = {}, + bool accent = false, const std::string& tip = {}); +// A pill with a count or a short word, like the badges on a mod manager's nav. +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); +// 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, +// 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); + +// Draws only the rows a scrolling child actually shows. Unlike ImGuiListClipper +// this copes with rows of different heights, which one open row needs. +template +void virtual_rows(int count, Height height, Row row) { + // Both axes: a row leaves the cursor wherever its last widget was. + const ImVec2 start = ImGui::GetCursorPos(); + const float scroll = ImGui::GetScrollY(), view = ImGui::GetWindowHeight(); + float y = start.y; + for (int index = 0; index < count; ++index) { + const float tall = height(index); + if (y + tall >= scroll && y <= scroll + view) { + ImGui::SetCursorPos(ImVec2(start.x, y)); + row(index, tall); + } + y += tall; + } + // An item at the end, so the child scrolls over every row and no further. + ImGui::SetCursorPos(ImVec2(start.x, y)); + ImGui::Dummy(ImVec2(1, 0)); +} void open_path(const fs::path& path); // Opens an https:// page in the default browser; anything else is ignored. void open_url(std::string_view url); @@ -232,6 +281,8 @@ struct Ui { std::array code{}; // The MODS tile's "2 of 3 enabled", re-read every few seconds. std::string mods_detail; + // Thunderstore updates waiting, for the MODS tile's badge. + std::size_t mods_updates{}; double mods_checked{-100}; // Steam display name for the name plate, re-read every few seconds. std::string steam_name; @@ -259,11 +310,13 @@ struct Store { std::vector packages; double fetched{-1e9}; // ImGui time the last fetch started - // The BROWSE page. + // The GET MODS page. std::array search{}; std::string category; // empty = all int sort{}; - std::string selected; // full_name + std::string selected; // full_name, while its overview is open + bool overview{}; // the overview popup is showing + std::vector picked; // ticked, to install in one go ~Store() { stop = true; @@ -297,8 +350,9 @@ struct ModsPanel { bool scanned{}; fs::path root; mods::ModList list; - int selected{-1}; - int tab{}; // 0 INSTALLED, 1 BROWSE + int selected{-1}; // the mod whose overview is open + bool overview{}; // the overview popup is showing + int tab{}; // 0 MY MODS, 1 GET MODS std::string message; bool message_error{}; std::string confirm_remove; // folder awaiting "Remove" confirmation @@ -325,6 +379,8 @@ struct ModsPanel { }; void scan(ModsPanel& panel, const launcher_app::Session& session); +// Re-reads the Mods folder and the Thunderstore listing. +void refresh_mods(Launcher& launcher, ModsPanel& panel); void start_install(ModsPanel& panel, const fs::path& source, bool replace); // ---------------------------------------------------------------- Thunderstore (gui_mods_browse.cpp) @@ -342,15 +398,20 @@ void start_store_install(ModsPanel& panel, std::vector pa // The package's icon texture, or empty while it loads; uploads finished icons. ImTextureID package_icon(ModsPanel& panel, const thunderstore::Package& package); void pump_icons(ModsPanel& panel); -// The BROWSE page body, between the tabs and the footer. -void browse_page(const Fonts& fonts, ModsPanel& panel, float height, bool installing); +// The GET MODS page body, under the page header. +void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float height, bool installing); +// Everything Thunderstore knows about one package, in a popup over the page. +void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool installing); +// Whether `package` is ticked for the next install, and ticking it. +bool picked(const Store& store, const std::string& full_name); +void pick(Store& store, const std::string& full_name, bool on); void open_sign_in(Launcher& launcher, Ui& ui, bool validate); void sign_in_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui); void prompt_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, const update::Prompt& prompt, Ui& ui); void qr_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, const std::vector& rows); void steam_offline_window(const Fonts& fonts, ImVec2 size, Ui& ui); -void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui); +void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, HWND window); void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel, HWND window); // The main screen: background, tiles, status and whichever panel is open. diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index 127fc1f..c6ddd47 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -238,8 +238,8 @@ void Launcher::fail(const std::string& message) { logging::write(logging::Level::error, logging::Channel::launcher, message); std::lock_guard lock(mutex_); state_.phase = Phase::failed; - state_.status = message; - state_.detail = "See logs\\ReSkate.log for details."; + state_.status = message; // raw: the STATUS tile explains it and can copy it + state_.detail.clear(); state_.progress = -1; state_.qr.clear(); } diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 02fbab4..9c15c39 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -11,7 +11,9 @@ using Microsoft::WRL::ComPtr; -// The Mods panel: install order, enabled state and installs in the background. +// 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 { @@ -57,29 +59,6 @@ void collect_install(ModsPanel& panel, const launcher_app::Session& session) { logging::write(logging::Level::info, logging::Channel::launcher, "Mod installed: " + panel.finished_name); } -// INSTALLED and BROWSE, styled like Settings' tabs; a blue count marks updates. -void mods_tabs(ModsPanel& panel, std::size_t updates) { - static constexpr std::array names{"INSTALLED", "BROWSE"}; - for (int i = 0; i < static_cast(names.size()); ++i) { - if (i) ImGui::SameLine(0, S(6)); - const bool selected = panel.tab == i; - if (selected) push_primary_button(); - if (ImGui::Button(names[static_cast(i)], ImVec2(S(150), S(34)))) panel.tab = i; - if (selected) pop_primary_button(); - if (i == 0 && updates) { - const auto corner = ImGui::GetItemRectMax(); - const auto text = std::to_string(updates); - const ImVec2 centre(corner.x - S(4), ImGui::GetItemRectMin().y + S(4)); - auto* draw = ImGui::GetWindowDrawList(); - draw->AddCircleFilled(centre, S(10), color::blue); - const auto extent = ImGui::CalcTextSize(text.c_str()); - draw->AddText(ImVec2(centre.x - extent.x * 0.5f, centre.y - extent.y * 0.5f), color::ink, text.c_str()); - if (ImGui::IsItemHovered()) ImGui::SetTooltip(updates == 1 ? "1 update on Thunderstore" : "%s updates on Thunderstore", text.c_str()); - } - } - ImGui::Spacing(); -} - // The Windows file (or folder) picker; empty when cancelled. fs::path pick(HWND owner, bool folder) { ComPtr dialog; @@ -116,61 +95,149 @@ std::string summary(const mods::Mod& mod, bool excluded = false) { return text; } -// The INSTALLED page body: install order and enabled state, and the selected mod's details. -void installed_page(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, float body, - bool installing) { - auto& entries = panel.list.entries; - const float list_width = (ImGui::GetContentRegionAvail().x - S(16)) * 0.58f; +// ---------------------------------------------------------------- nav rail - // ------------------------------------------------ list - ImGui::BeginChild("##mod_list", ImVec2(list_width, body), ImGuiChildFlags_Borders); - bool changed = false; - int move_from = -1, move_to = -1; +// 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& 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 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(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(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 Install below, or drop a mod .zip on the window."); + 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); - const float arrows = ImGui::GetFrameHeight() * 2 + ImGui::GetStyle().ItemSpacing.x; - for (int i = 0; i < static_cast(entries.size()); ++i) { - auto& entry = entries[static_cast(i)]; - ImGui::PushID(i); - if (ImGui::Checkbox("##enabled", &entry.enabled)) changed = true; - if (ImGui::IsItemHovered()) ImGui::SetTooltip(entry.enabled ? "Enabled: loads when Skate starts" : "Disabled"); - ImGui::SameLine(); - const auto label = std::to_string(i + 1) + ". " + entry.mod.title; - ImGui::PushFont(fonts.bold); - if (!entry.enabled) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::muted)); - if (ImGui::Selectable(label.c_str(), panel.selected == i, ImGuiSelectableFlags_AllowOverlap, - ImVec2(ImGui::GetContentRegionAvail().x - arrows - S(8), 0))) - panel.selected = i; - if (!entry.enabled) ImGui::PopStyleColor(); - ImGui::PopFont(); - ImGui::SameLine(ImGui::GetWindowContentRegionMax().x - arrows); - ImGui::BeginDisabled(i == 0); - if (ImGui::ArrowButton("##up", ImGuiDir_Up)) { move_from = i; move_to = i - 1; } - ImGui::EndDisabled(); - ImGui::SameLine(); - ImGui::BeginDisabled(i + 1 == static_cast(entries.size())); - if (ImGui::ArrowButton("##down", ImGuiDir_Down)) { move_from = i; move_to = i + 1; } - ImGui::EndDisabled(); - const bool left_out = panel.list.excluded.contains(entry.mod.name); - const auto* package = package_for(panel.store, entry.mod.name); - const bool update = package && thunderstore::update_available(*package, installed); - if (const auto detail = summary(entry.mod, left_out); !detail.empty() || update) { - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.x); - if (update) { - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::blue), "UPDATE v%s", package->latest().number.c_str()); - if (!detail.empty()) ImGui::SameLine(); - } - if (!detail.empty()) ImGui::TextDisabled("%s", detail.c_str()); - } - ImGui::Spacing(); - ImGui::PopID(); - } + virtual_rows(static_cast(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(move_from)], entries[static_cast(move_to)]); if (panel.selected == move_from) panel.selected = move_to; @@ -178,106 +245,163 @@ void installed_page(const Fonts& fonts, ModsPanel& panel, const thunderstore::In changed = true; } if (changed) save(panel); +} - // ------------------------------------------------ details - ImGui::SameLine(0, S(16)); - ImGui::BeginChild("##mod_details", ImVec2(0, body), ImGuiChildFlags_Borders); - if (panel.selected >= 0 && panel.selected < static_cast(entries.size())) { - const auto& entry = entries[static_cast(panel.selected)]; - const auto& mod = entry.mod; - ImGui::PushFont(fonts.heading); - ImGui::PushTextWrapPos(0); - ImGui::TextUnformatted(mod.title.c_str()); - ImGui::PopTextWrapPos(); - ImGui::PopFont(); - ImGui::TextDisabled("%s", entry.enabled ? "Enabled" : "Disabled"); - ImGui::Spacing(); - const auto field = [&](const char* name, const std::string& value) { - if (value.empty()) return; - ImGui::PushFont(fonts.caption); - ImGui::TextDisabled("%s", name); - ImGui::PopFont(); - ImGui::PushTextWrapPos(0); - ImGui::TextUnformatted(value.c_str()); - ImGui::PopTextWrapPos(); - }; - if (!mod.outdated.empty()) { - ImGui::PushTextWrapPos(0); - 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()); - ImGui::PopTextWrapPos(); - ImGui::Spacing(); - } - if (const auto left_out = panel.list.excluded.find(mod.name); left_out != panel.list.excluded.end()) { - ImGui::PushTextWrapPos(0); - 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 (!left_out->second.empty()) ImGui::TextDisabled("%s", left_out->second.front().c_str()); - ImGui::PopTextWrapPos(); - ImGui::Spacing(); - } - field("AUTHOR", mod.author); - field("VERSION", mod.version); - field("DESCRIPTION", mod.description); - field("FOLDER", "Mods\\" + mod.name); - const auto* package = package_for(panel.store, mod.name); - const bool update = package && thunderstore::update_available(*package, installed); - if (package) - field("THUNDERSTORE", package->full_name + (update ? " (v" + package->latest().number + " available)" - : std::string(" (up to date)"))); - if (!mod.tool.empty() || !mod.built.empty()) - field("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("MAPS", levels); - std::string parks; - for (const auto& map : mod.park_maps) parks += (parks.empty() ? "" : ", ") + map; - field("PARKS", parks); - if (!mod.provides_layout && !mod.provides_levels && mod.park_maps.empty()) - field("NOTE", "This folder has no layout.toc or reskate-levels.json, so the game has nothing to load from it."); - ImGui::Spacing(); - if (update) { - ImGui::BeginDisabled(installing); - push_primary_button(); - if (ImGui::Button(("UPDATE TO v" + package->latest().number).c_str())) start_store_install(panel, {*package}); - pop_primary_button(); - ImGui::EndDisabled(); - ImGui::SameLine(); - } - if (ImGui::Button("Open folder")) open_path(mod.directory); - if (package && !package->package_url.empty()) { - ImGui::SameLine(); - if (ImGui::Button("Thunderstore page")) - open_url(package->package_url); - } - ImGui::SameLine(); - ImGui::BeginDisabled(installing); - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger)); - if (ImGui::Button("Remove")) panel.confirm_remove = mod.name; - ImGui::PopStyleColor(); - ImGui::EndDisabled(); - } else { - ImGui::Spacing(); - ImGui::TextDisabled("Select a mod to see its details."); - if (!panel.list.missing.empty()) { - ImGui::Spacing(); - ImGui::PushTextWrapPos(0); - 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(); - } +// 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(panel.list.entries.size())) { + panel.selected = -1; + return; } + auto& entry = panel.list.entries[static_cast(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(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()); @@ -291,8 +415,12 @@ void start_install(ModsPanel& panel, const fs::path& source, bool replace) { panel.installing = true; panel.cancel = false; panel.progress = 0; - panel.message = "Installing " + utf8(source.filename().wstring()) + "..."; + 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; @@ -310,6 +438,7 @@ void start_install(ModsPanel& panel, const fs::path& source, bool replace) { panel.finished_error = error; panel.finished_conflict = conflict; panel.finished_source = source; + panel.activity.clear(); panel.installing = false; }); } @@ -319,91 +448,53 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo if (!panel.scanned) scan(panel, session); collect_install(panel, session); refresh_listing(panel, ImGui::GetTime()); - const auto frame = begin_panel("##mods_panel", size, ImVec2(S(960), S(620))); + // 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 bool browsing = panel.tab == 1; + const auto close = [&] { ui.mods = false; panel.message.clear(); panel.scanned = false; }; - panel_title(fonts, "MODS"); - mods_tabs(panel, pending.size()); - ImGui::PushTextWrapPos(0); - if (browsing) - ImGui::TextDisabled("Mods published on Thunderstore (thunderstore.io/c/reskate). Installed mods load the next " - "time Skate starts; updates appear here and on the INSTALLED tab."); - else - ImGui::TextDisabled("Mods load top to bottom: where two mods change the same thing, the higher one wins. " - "Changes apply the next time Skate starts. Drop a .zip or a mod folder on this window to install it."); - ImGui::PopTextWrapPos(); - if (launcher.game()) - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), "Skate is running: restart it to apply changes."); - if (!panel.list.issue.empty()) { - ImGui::PushTextWrapPos(0); - 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()); - ImGui::PopTextWrapPos(); - } - ImGui::Spacing(); + // 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 footer = ImGui::GetFrameHeightWithSpacing() * 2 + S(30); - const float body = std::max(S(120), frame.y - ImGui::GetCursorPosY() - footer); + 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(); - if (browsing) browse_page(fonts, panel, body, installing); - else installed_page(fonts, panel, installed, body, installing); - - // ------------------------------------------------ status and actions - ImGui::Spacing(); - if (installing) { - 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 overlay = activity.empty() ? std::string() : std::format("{} {:.0f}%", activity, fraction * 100); - ImGui::ProgressBar(fraction, ImVec2(-S(120), 0), overlay.empty() ? nullptr : overlay.c_str()); - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(-1, 0))) panel.cancel = true; - } else if (!panel.message.empty()) { + 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::SetCursorPosY(frame.y - S(24) - ImGui::GetFrameHeight()); - ImGui::BeginDisabled(installing); - if (browsing) { - if (ImGui::Button("Open Thunderstore")) open_url(utf8(thunderstore::community_page(thunderstore::community()))); - ImGui::SameLine(); - ImGui::BeginDisabled(panel.store.loading); - if (ImGui::Button("Refresh")) { scan(panel, session); refresh_listing(panel, ImGui::GetTime(), true); panel.message.clear(); } - ImGui::EndDisabled(); - } else { - if (ImGui::Button("Install .zip")) - if (const auto path = pick(window, false); !path.empty()) start_install(panel, path, false); - ImGui::SameLine(); - if (ImGui::Button("Install folder")) - if (const auto path = pick(window, true); !path.empty()) start_install(panel, path, false); - ImGui::SameLine(); - if (ImGui::Button("Open Mods folder")) open_path(panel.root); - ImGui::SameLine(); - if (ImGui::Button("Refresh")) { scan(panel, session); panel.message.clear(); } - if (!pending.empty()) { - ImGui::SameLine(); - if (ImGui::Button(std::format("Update all ({})", pending.size()).c_str())) { - std::vector packages; - for (const auto* package : pending) packages.push_back(*package); - start_store_install(panel, std::move(packages)); - } - } - } - ImGui::SameLine(frame.x - S(28) - S(110)); - push_primary_button(); - if (ImGui::Button("DONE", ImVec2(S(110), 0))) { ui.mods = false; panel.message.clear(); panel.scanned = false; } - pop_primary_button(); - ImGui::EndDisabled(); + 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"); @@ -463,6 +554,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo pop_primary_button(); ImGui::EndPopup(); } + g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId); ImGui::End(); } diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 1335134..a0ef3e0 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -232,15 +232,14 @@ std::vector visible_packages(const Store& store, const ts::I constexpr std::array sort_names{"Last updated", "Most downloaded", "Newest", "Top rated", "Name"}; -void draw_icon(ModsPanel& panel, const ts::Package& package, float size) { - const ImVec2 start = ImGui::GetCursorScreenPos(); - const ImVec2 end(start.x + size, start.y + size); +// Placed by hand: a row lays its own icon, text and badges out. +void draw_icon(ModsPanel& panel, const ts::Package& package, ImVec2 position, float size) { + const ImVec2 end(position.x + size, position.y + size); auto* draw = ImGui::GetWindowDrawList(); if (const auto id = package_icon(panel, package)) - draw->AddImageRounded(id, start, end, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, S(4)); + draw->AddImageRounded(id, position, end, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, S(4)); else - draw->AddRectFilled(start, end, rgba(255, 255, 255, 0.06f), S(4)); - ImGui::Dummy(ImVec2(size, size)); + draw->AddRectFilled(position, end, rgba(255, 255, 255, 0.06f), S(4)); } // What installing `package` does now: install, update or reinstall. @@ -400,14 +399,26 @@ void pump_icons(ModsPanel& panel) { } } -void browse_page(const Fonts& fonts, ModsPanel& panel, float height, bool installing) { +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); pump_icons(panel); - // ------------------------------------------------ filters + // ------------------------------------------------ search, category, sort, refresh const float top = ImGui::GetCursorPosY(); - ImGui::SetNextItemWidth(S(300)); + 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 categories; @@ -415,7 +426,7 @@ void browse_page(const Fonts& fonts, ModsPanel& panel, float height, bool instal 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(S(170)); + 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) @@ -423,163 +434,250 @@ void browse_page(const Fonts& fonts, ModsPanel& panel, float height, bool instal ImGui::EndCombo(); } ImGui::SameLine(); - ImGui::SetNextItemWidth(S(170)); + ImGui::SetNextItemWidth(combo); if (ImGui::BeginCombo("##sort", sort_names[static_cast(std::clamp(store.sort, 0, 4))])) { for (int i = 0; i < static_cast(sort_names.size()); ++i) if (ImGui::Selectable(sort_names[static_cast(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 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 float list_width = (ImGui::GetContentRegionAvail().x - S(16)) * 0.58f; const auto packages = visible_packages(store, installed); - // ------------------------------------------------ list - ImGui::BeginChild("##store_list", ImVec2(list_width, body), ImGuiChildFlags_Borders); - const auto centred = [](const char* text) { + // ------------------------------------------------ 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()) centred("Loading mods from Thunderstore..."); + 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()) { - centred("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."); + 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()) { - centred("No mods match your search."); + note("No mods match your search."); } - const float row = S(68); - ImGuiListClipper clipper; - clipper.Begin(static_cast(packages.size()), row + ImGui::GetStyle().ItemSpacing.y); - while (clipper.Step()) { - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { - const auto& package = *packages[static_cast(i)]; - ImGui::PushID(package.full_name.c_str()); - const auto start = ImGui::GetCursorPos(); - if (ImGui::Selectable("##row", store.selected == package.full_name, ImGuiSelectableFlags_AllowOverlap, - ImVec2(0, row))) - store.selected = package.full_name; - const float right = ImGui::GetWindowContentRegionMax().x; - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(S(8), S(3))); - ImGui::SetCursorPos(ImVec2(start.x + S(6), start.y + S(10))); - draw_icon(panel, package, S(48)); - const float text_x = start.x + S(66); - ImGui::SetCursorPos(ImVec2(text_x, start.y + S(7))); - ImGui::PushFont(fonts.bold); - ImGui::TextUnformatted(package.title().c_str()); - ImGui::PopFont(); - ImGui::SameLine(); - ImGui::TextDisabled("by %s", package.owner.c_str()); - const auto found = installed.find(ts::folder_for(package.full_name)); - const char* badge = found == installed.end() ? nullptr - : ts::update_available(package, installed) ? "UPDATE" : "INSTALLED"; - if (badge) { - const float width = ImGui::CalcTextSize(badge).x; - ImGui::SameLine(right - width - S(6)); - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(std::string_view(badge) == "UPDATE" ? color::blue : color::good), - "%s", badge); - } - ImGui::SetCursorPosX(text_x); - auto line = package.latest().description; - std::replace_if(line.begin(), line.end(), [](char ch) { return ch == '\n' || ch == '\r' || ch == '\t'; }, ' '); - ImGui::PushClipRect(ImGui::GetCursorScreenPos(), - ImVec2(ImGui::GetWindowPos().x + right - S(6), ImGui::GetCursorScreenPos().y + row), true); - ImGui::TextUnformatted(line.c_str()); - ImGui::PopClipRect(); - ImGui::SetCursorPosX(text_x); - ImGui::TextDisabled("v%s / %s downloads / %s", package.latest().number.c_str(), - count_text(package.downloads).c_str(), date_text(package.date_updated).c_str()); - ImGui::PopStyleVar(); - // An item at the row's end, so the cursor never extends the list on its own. - ImGui::SetCursorPos(ImVec2(start.x, start.y + row)); - ImGui::Dummy(ImVec2(1, 0)); - ImGui::PopID(); - } - } - ImGui::EndChild(); - // ------------------------------------------------ details - ImGui::SameLine(0, S(16)); - ImGui::BeginChild("##store_details", ImVec2(0, body), ImGuiChildFlags_Borders); + const float row = S(78); + ImGui::BeginDisabled(installing); + virtual_rows(static_cast(packages.size()), [&](int) { return row; }, [&](int index, float tall) { + const auto& package = *packages[static_cast(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); + draw_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) { - ImGui::Spacing(); - ImGui::TextDisabled(store.packages.empty() ? "" : "Select a mod to see its details."); - } else { - const auto& package = *selected; - const auto& version = package.latest(); - // Icon and name side by side, then the actions, so INSTALL never needs scrolling to. - draw_icon(panel, package, S(80)); - ImGui::SameLine(0, S(14)); - 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()); - ImGui::EndGroup(); - ImGui::Spacing(); - // 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"); - if (tool) { - push_primary_button(); - if (ImGui::Button("GET IT ON THUNDERSTORE")) open_url(package.package_url); - pop_primary_button(); - } else { - ImGui::BeginDisabled(installing); - push_primary_button(); - if (ImGui::Button(action_label(package, installed).c_str())) start_store_install(panel, {package}); - pop_primary_button(); - ImGui::EndDisabled(); - if (!package.package_url.empty()) { - ImGui::SameLine(); - if (ImGui::Button("Thunderstore page")) open_url(package.package_url); - } - } - if (version.website_url.starts_with("https://")) { - ImGui::SameLine(); - if (ImGui::Button("Website")) open_url(version.website_url); - } - ImGui::Spacing(); - const auto field = [&](const char* name, const std::string& value) { - if (value.empty()) return; - ImGui::PushFont(fonts.caption); - ImGui::TextDisabled("%s", name); - ImGui::PopFont(); - ImGui::PushTextWrapPos(0); - ImGui::TextUnformatted(value.c_str()); - ImGui::PopTextWrapPos(); - }; - if (package.deprecated) { - ImGui::PushTextWrapPos(0); - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), "Deprecated: its author no longer supports it."); - ImGui::PopTextWrapPos(); - } - field("DESCRIPTION", version.description); - const auto found = installed.find(ts::folder_for(package.full_name)); - field("VERSION", "v" + version.number + (found == installed.end() ? std::string() - : found->second.empty() ? " (installed)" : " (installed: v" + found->second + ")")); - field("DOWNLOADS", count_text(package.downloads)); - field("UPDATED", date_text(package.date_updated)); - if (version.file_size) field("DOWNLOAD SIZE", size_text(version.file_size)); - std::string categories_text; - for (const auto& category : package.categories) categories_text += (categories_text.empty() ? "" : ", ") + category; - field("CATEGORIES", categories_text); + 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(); + }; + draw_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 diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 5654415..394d5eb 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -101,37 +101,48 @@ bool key_bindings(const Fonts& fonts, Settings& settings, Ui& ui) { return changed; } -// Tab buttons styled like the launcher's buttons: the open tab is blue. -void settings_tabs(Ui& ui) { - static constexpr std::array names{"GAME", "DISPLAY", "KEYS", "ADVANCED"}; - ImGui::BeginDisabled(ui.binding != 0); - for (int i = 0; i < static_cast(names.size()); ++i) { - if (i) ImGui::SameLine(0, S(6)); - const bool selected = ui.settings_tab == i; - if (selected) push_primary_button(); - if (ImGui::Button(names[static_cast(i)], ImVec2(S(128), S(34)))) ui.settings_tab = i; - if (selected) pop_primary_button(); - } - ImGui::EndDisabled(); - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); -} - } // namespace -void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui) { +void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, HWND window) { bool& open = ui.settings; - const auto panel = begin_panel("##settings_panel", size, ImVec2(S(600), S(620))); + // A page, like the mod manager: the same rail, the same way out. + const auto frame = begin_page("##settings_panel", size); + auto* draw = ImGui::GetWindowDrawList(); auto& settings = launcher.settings(); const auto& paths = launcher.session().paths; const bool busy = launcher.busy(); + const bool binding = ui.binding != 0; - panel_title(fonts, "SETTINGS"); - settings_tabs(ui); + 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)), "SETTINGS", title_size); - const float footer = ImGui::GetFrameHeight() + S(40); - ImGui::BeginChild("##settings_page", ImVec2(0, panel.y - ImGui::GetCursorPosY() - footer), ImGuiChildFlags_None); + // ------------------------------------------------ rail + const float rail_top = top + title_size + S(20); + ImGui::SetCursorPos(ImVec2(rail_x, rail_top)); + ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top)); + static constexpr std::array names{"GAME", "DISPLAY", "KEYS", "ADVANCED"}; + ImGui::BeginDisabled(binding); + for (int i = 0; i < static_cast(names.size()); ++i) + if (nav_tile(fonts, rail_width, names[static_cast(i)], ui.settings_tab == i)) + ui.settings_tab = i; + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38)); + push_primary_button(); + if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) open = false; + pop_primary_button(); + ImGui::EndDisabled(); + ImGui::EndChild(); + + // ------------------------------------------------ the open tab + // Kept to a readable column: settings are sentences, not a table. + ImGui::SetCursorPos(ImVec2(content_x, top)); + ImGui::BeginChild("##settings_content", + ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top)); + const float footer = ImGui::GetTextLineHeight() + S(16); + ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer)); switch (ui.settings_tab) { case 0: { section_caption(fonts, "GAME FOLDER"); @@ -211,19 +222,16 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui } ImGui::EndChild(); - ImGui::SetCursorPosY(panel.y - S(24) - ImGui::GetFrameHeight()); - ImGui::AlignTextToFramePadding(); + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - footer + S(8)); ImGui::PushFont(fonts.caption); ImGui::TextDisabled("%s", !update::binary_updates_enabled() ? "Development build: ReSkate files are never replaced." : settings.updates ? "Release build: updates install automatically." : "Updates are off: ReSkate files are never replaced."); ImGui::PopFont(); - ImGui::SameLine(panel.x - S(28) - S(110)); - push_primary_button(); - ImGui::BeginDisabled(ui.binding != 0); - if (ImGui::Button("DONE", ImVec2(S(110), 0))) open = false; - ImGui::EndDisabled(); - pop_primary_button(); + ImGui::EndChild(); + + if (!binding && !ImGui::IsAnyItemActive() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) open = false; + g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId); ImGui::End(); if (!open) launcher.save(); } diff --git a/Launcher/problem.h b/Launcher/problem.h new file mode 100644 index 0000000..c572ac0 --- /dev/null +++ b/Launcher/problem.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include + +// Failures are logged in the words that help whoever reads ReSkate.log, which +// are rarely the words that help whoever is trying to skate. This turns one +// into the other: what went wrong, and what to do about it. The raw message +// stays in the log, and the STATUS tile can still copy it. +namespace dingosdk::launcher_problem { + +struct Problem { + std::string headline; // what went wrong, in a line + std::string advice; // the next thing to try +}; + +inline bool mentions(std::string_view raw, std::string_view needle) { + return raw.find(needle) != std::string_view::npos; +} + +// Anti-virus and overlays are behind most injection failures, so they share +// one piece of advice. +inline constexpr std::string_view attach_advice = + "Anti-virus software usually blocks this. Add your skate. folder as an exclusion, close overlays " + "(Discord, MSI Afterburner, RTSS), then press RETRY."; + +inline Problem explain(std::string_view raw) { + // Most specific first: the later rules match whole families of failures. + if (mentions(raw, "must be beside ReSkateLauncher.exe")) + return {"ReSkate is not in your skate. folder", + "Put ReSkateLauncher.exe and ReSkate.dll beside Skate.exe, in the folder Steam opens with " + "skate. > Manage > Browse local files."}; + if (mentions(raw, "ReSkate.dll is missing")) + return {"ReSkate.dll is missing", + "Extract ReSkateLauncher.exe and ReSkate.dll from the same release zip into this folder, " + "then press RETRY."}; + if (mentions(raw, "ReSkate.dll is not an x64") || mentions(raw, "DingoSDKDebugInitialize is outside")) + return {"ReSkate.dll is damaged", + "Download the release zip again and extract both files over this folder. Your anti-virus may " + "also have quarantined part of it."}; + if (mentions(raw, "Skate.exe is missing")) + return {"Skate.exe is not in this folder", + "Run the launcher from your skate. folder, or press INSTALL to download the game here."}; + if (mentions(raw, "steam_api64.dll is missing") || mentions(raw, "steam_api64")) + return {"The original steam_api64.dll is missing", + "Verify the game files in Steam, or press DOWNLOAD to fetch the supported build again."}; + if (mentions(raw, "This launcher is out of date")) + return {"This launcher is out of date", + "Download the newest ReSkate release from GitHub and extract it over this folder."}; + if (mentions(raw, "update server could not be reached")) + return {"The ReSkate update server could not be reached", + "Check your internet connection and press RETRY. If the game files are already correct you can " + "turn on Offline mode in Settings and play without the check."}; + if (mentions(raw, "not supported")) + return {"This copy of skate. is not the supported build", + "Press DOWNLOAD to get the supported build with your Steam account. Only the files that differ " + "are downloaded."}; + if (mentions(raw, "Steam download did not finish")) + return {"The Steam download did not finish", + "Check that the account you signed in with owns skate. and that you have about 14 GB free, " + "then press DOWNLOAD again. Signing in again often clears it."}; + if (mentions(raw, "installing the game content cache")) + return {"Another ReSkate launcher is busy", + "It is installing the game data this build needs. Wait for it to finish, then press RETRY."}; + if (mentions(raw, "content cache")) + return {"The one-time game data download failed", + "ReSkate downloads the game's catalogues once per build. Check your internet connection and " + "press RETRY."}; + if (mentions(raw, "need different keys")) + return {"The menu and console share a key", + "Open Settings > KEYS and pick a different key for one of them."}; + if (mentions(raw, "Skate closed while it was starting")) + return {"Skate closed while it was starting", std::string(attach_advice)}; + if (mentions(raw, "timed out after")) + return {"Skate took too long to start", + "A slow disk or an anti-virus scan usually causes this. Press RETRY; if it keeps happening, add " + "your skate. folder as an exclusion."}; + if (mentions(raw, "validated Skate.exe") || mentions(raw, "loaded-image") || mentions(raw, "DOS header") || + mentions(raw, "module list") || mentions(raw, "VirtualAllocEx") || mentions(raw, "NtQueryInformationProcess") || + mentions(raw, "DingoSDKDebugInitialize") || mentions(raw, "Remote ")) + return {"ReSkate could not attach to Skate", std::string(attach_advice)}; + return {std::string(raw), + "Press RETRY. If it keeps happening, copy the details below and share them with logs\\ReSkate.log " + "on the ReSkate Discord."}; +} + +} // namespace dingosdk::launcher_problem diff --git a/assets/launcher/background.jpg b/assets/launcher/background.jpg index 18079dc..2c80aeb 100644 Binary files a/assets/launcher/background.jpg and b/assets/launcher/background.jpg differ diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 2f9b59e..a1ea732 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -1,7 +1,7 @@ add_executable(dingosdk_launcher WIN32 Launcher/main.cpp Launcher/launch.cpp Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp - Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/thunderstore.cpp) + Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/thunderstore.cpp Launcher/problem.h) target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs dingosdk_mod_list dingosdk_json dingosdk_miniz dingosdk_imgui winhttp shell32 dwmapi windowscodecs ole32) set_target_properties(dingosdk_launcher PROPERTIES OUTPUT_NAME "ReSkateLauncher")