Rebuild the launcher's mod manager, settings and status tile

The mod browser was a 960x620 panel inside a 1180x680 window, reached from
a tile the same size as SETTINGS. People could not find it, and once they
did there was room for about six mods.

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

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

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

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

No files matched your search

+98 -2
View File
@@ -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<unsigned>(*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<std::wstring>& 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<float>(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<float>(work.right - work.left);
const auto work_height = static_cast<float>(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<std::wstring>& a
window_class.lpszClassName = L"ReSkateLauncher";
RegisterClassExW(&window_class);
RECT work{};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
const int width = static_cast<int>(S(design_width));
const int height = static_cast<int>(S(design_height));
const HWND window = CreateWindowExW(WS_EX_APPWINDOW, window_class.lpszClassName, L"ReSkate",
+482 -430
View File
@@ -1,430 +1,482 @@
#include "gui_internal.h"
#include "mod_manager.h"
#include <cmath>
#include <format>
// 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<float>(column), y0 = cell_y * static_cast<float>(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<float>(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<float>(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<float>(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<float>(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<float>(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<float>(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<Row> 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<float>(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 <cmath>
#include <format>
// 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<float>(column), y0 = cell_y * static_cast<float>(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<float>(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<float>(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<float>(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<float>(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<float>(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<float>(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<Fact> 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<float>(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<float>(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
+70 -9
View File
@@ -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<int>(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<class Height, class Row>
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<char, 16> 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<thunderstore::Package> packages;
double fetched{-1e9}; // ImGui time the last fetch started
// The BROWSE page.
// The GET MODS page.
std::array<char, 96> 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<std::string> 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<thunderstore::Package> 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<std::string>& 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.
+2 -2
View File
@@ -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();
}
+330 -238
View File
@@ -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<const char*, 2> names{"INSTALLED", "BROWSE"};
for (int i = 0; i < static_cast<int>(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<std::size_t>(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<IFileOpenDialog> 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<const thunderstore::Package*>& pending, bool installing) {
const auto installed = panel.list.entries.size();
const auto& store = panel.store;
bool leave = false;
ImGui::BeginDisabled(installing);
if (nav_tile(fonts, width, "MY MODS", panel.tab == 0,
pending.empty() ? std::to_string(installed) : std::to_string(pending.size()), !pending.empty(),
pending.empty() ? std::string()
: pending.size() == 1 ? std::string("1 update on Thunderstore")
: std::format("{} updates on Thunderstore", pending.size())))
panel.tab = 0;
if (nav_tile(fonts, width, "GET MODS", panel.tab == 1,
store.loaded ? std::to_string(store.packages.size()) : std::string("..."), false,
store.loaded ? std::string() : std::string("Loading the Thunderstore listing")))
panel.tab = 1;
ImGui::Dummy(ImVec2(0, S(8)));
if (!pending.empty()) {
push_primary_button();
if (ImGui::Button(std::format("UPDATE ALL ({})", pending.size()).c_str(), ImVec2(-1, S(34)))) {
std::vector<thunderstore::Package> packages;
for (const auto* package : pending) packages.push_back(*package);
start_store_install(panel, std::move(packages));
}
pop_primary_button();
ImGui::Dummy(ImVec2(0, S(4)));
}
if (ImGui::Button("Install .zip", ImVec2(-1, S(32))))
if (const auto path = pick(window, false); !path.empty()) start_install(panel, path, false);
if (ImGui::Button("Install folder", ImVec2(-1, S(32))))
if (const auto path = pick(window, true); !path.empty()) start_install(panel, path, false);
if (ImGui::Button("Open Mods folder", ImVec2(-1, S(32)))) open_path(panel.root);
if (ImGui::Button("Open Thunderstore", ImVec2(-1, S(32))))
open_url(utf8(thunderstore::community_page(thunderstore::community())));
ImGui::EndDisabled();
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38));
push_primary_button();
if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) leave = true;
pop_primary_button();
return leave;
}
// ---------------------------------------------------------------- MY MODS
// One installed mod: enabled state, its place in the load order, and a click
// anywhere else for everything the mod says about itself.
void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, int index,
float tall, bool& changed, int& move_from, int& move_to) {
auto& entry = panel.list.entries[static_cast<std::size_t>(index)];
const auto& mod = entry.mod;
ImGui::PushID(index);
auto* draw = ImGui::GetWindowDrawList();
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
if (list_row("##row", width, tall, false)) {
panel.selected = index;
panel.overview = false;
}
const float right = start.x + width;
const bool left_out = panel.list.excluded.contains(mod.name);
const auto* package = package_for(panel.store, mod.name);
const bool update = package && thunderstore::update_available(*package, installed);
const auto title = std::to_string(index + 1) + ". " + mod.title;
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(start.x + S(54), start.y + S(12)),
entry.enabled ? color::text : color::muted, title.c_str());
if (const auto detail = summary(mod, left_out); !detail.empty())
draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(start.x + S(54), start.y + S(36)), color::muted,
detail.c_str());
// The widgets that sit on the row, over its Selectable.
ImGui::SetCursorScreenPos(ImVec2(start.x + S(16), start.y + (tall - ImGui::GetFrameHeight()) * 0.5f));
if (ImGui::Checkbox("##enabled", &entry.enabled)) changed = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip(entry.enabled ? "Enabled: loads when Skate starts" : "Disabled");
const float arrow = ImGui::GetFrameHeight();
const float arrows_x = right - S(20) - arrow * 2 - S(6);
if (update) {
const float pill = badge_width(fonts, "UPDATE");
badge(draw, fonts, ImVec2(arrows_x - S(14) - pill, start.y + (tall - fonts.caption->FontSize - S(8)) * 0.5f),
"UPDATE");
}
ImGui::SetCursorScreenPos(ImVec2(arrows_x, start.y + (tall - arrow) * 0.5f));
ImGui::BeginDisabled(index == 0);
if (ImGui::ArrowButton("##up", ImGuiDir_Up)) { move_from = index; move_to = index - 1; }
ImGui::EndDisabled();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Load earlier: later mods win where they overlap");
ImGui::SameLine(0, S(6));
ImGui::BeginDisabled(index + 1 == static_cast<int>(panel.list.entries.size()));
if (ImGui::ArrowButton("##down", ImGuiDir_Down)) { move_from = index; move_to = index + 1; }
ImGui::EndDisabled();
ImGui::PopID();
}
void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed,
float height, bool installing) {
auto& entries = panel.list.entries;
const float top = ImGui::GetCursorPosY();
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - S(110));
if (ImGui::Button("Refresh", ImVec2(S(110), 0))) refresh_mods(launcher, panel);
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("Mods load top to bottom: where two change the same thing, the higher one wins. Changes apply "
"the next time Skate starts. Click a mod to see everything about it.");
if (launcher.game())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
"Skate is running: restart it to apply changes.");
if (!panel.list.issue.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"mods.json could not be read (%s), so the game loads no mods. Any change here rewrites it.",
panel.list.issue.c_str());
if (!panel.list.missing.empty()) {
std::string missing;
for (const auto& name : panel.list.missing) missing += (missing.empty() ? "" : ", ") + name;
ImGui::TextDisabled("mods.json also lists folders that are not installed: %s", missing.c_str());
}
ImGui::PopTextWrapPos();
ImGui::Spacing();
const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top));
ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders);
if (entries.empty()) {
ImGui::Spacing();
ImGui::Indent(S(14));
ImGui::TextDisabled(panel.list.present ? "No mods installed yet." : "No Mods folder yet.");
ImGui::TextDisabled("Use 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<int>(entries.size()); ++i) {
auto& entry = entries[static_cast<std::size_t>(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<int>(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<int>(entries.size()), [&](int) { return row; }, [&](int index, float tall) {
installed_row(fonts, panel, installed, index, tall, changed, move_from, move_to);
});
ImGui::EndDisabled();
ImGui::EndChild();
if (move_from >= 0) {
std::swap(entries[static_cast<std::size_t>(move_from)], entries[static_cast<std::size_t>(move_to)]);
if (panel.selected == move_from) panel.selected = move_to;
@@ -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<int>(entries.size())) {
const auto& entry = entries[static_cast<std::size_t>(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<int>(panel.list.entries.size())) {
panel.selected = -1;
return;
}
auto& entry = panel.list.entries[static_cast<std::size_t>(panel.selected)];
const auto& mod = entry.mod;
const auto* package = package_for(panel.store, mod.name);
const bool update = package && thunderstore::update_available(*package, installed);
if (!panel.overview) {
ImGui::OpenPopup("##mod_overview");
panel.overview = true;
}
const ImVec2 extent(std::min(S(680), size.x - S(80)), std::min(S(560), size.y - S(80)));
ImGui::SetNextWindowPos(ImVec2((size.x - extent.x) * 0.5f, (size.y - extent.y) * 0.5f));
ImGui::SetNextWindowSize(extent);
if (!ImGui::BeginPopupModal("##mod_overview", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings)) {
panel.overview = false; // dismissed with Escape
panel.selected = -1;
return;
}
const auto close = [&] {
panel.overview = false;
panel.selected = -1;
ImGui::CloseCurrentPopup();
};
ImGui::PushFont(fonts.heading);
ImGui::PushTextWrapPos(0);
ImGui::TextUnformatted(mod.title.c_str());
ImGui::PopTextWrapPos();
ImGui::PopFont();
ImGui::TextDisabled("%s", summary(mod, panel.list.excluded.contains(mod.name)).c_str());
ImGui::Spacing();
if (update) {
ImGui::BeginDisabled(installing);
push_primary_button();
if (ImGui::Button(("UPDATE TO v" + package->latest().number).c_str(), ImVec2(0, S(34)))) {
start_store_install(panel, {*package});
close();
}
pop_primary_button();
ImGui::EndDisabled();
ImGui::SameLine();
}
if (ImGui::Button("Open folder", ImVec2(0, S(34)))) open_path(mod.directory);
if (package && !package->package_url.empty()) {
ImGui::SameLine();
if (ImGui::Button("Thunderstore page", ImVec2(0, S(34)))) open_url(package->package_url);
}
ImGui::SameLine();
ImGui::BeginDisabled(installing);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
// The confirmation is its own modal, so this one has to go first.
if (ImGui::Button("Remove", ImVec2(0, S(34)))) {
const auto name = mod.name;
close();
panel.confirm_remove = name;
}
ImGui::PopStyleColor();
ImGui::EndDisabled();
ImGui::Spacing();
ImGui::BeginChild("##mod_overview_body",
ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())));
ImGui::PushTextWrapPos(0);
if (!mod.outdated.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"Outdated: this mod does not load. It was made for another version of Skate (%s). Get an updated "
"version, or rebuild it with the latest ReSkate Studio.", mod.outdated.c_str());
if (const auto missing = panel.list.excluded.find(mod.name); missing != panel.list.excluded.end()) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
"Not loaded: the game could not merge this mod cleanly, so none of it is used. Reinstall the whole "
"mod folder, or rebuild it with a current ReSkate Studio.");
if (!missing->second.empty()) ImGui::TextDisabled("%s", missing->second.front().c_str());
}
field(fonts, "AUTHOR", mod.author);
field(fonts, "VERSION", mod.version);
field(fonts, "DESCRIPTION", mod.description);
field(fonts, "FOLDER", "Mods\\" + mod.name);
if (package)
field(fonts, "THUNDERSTORE", package->full_name + (update ? " (v" + package->latest().number + " available)"
: std::string(" (up to date)")));
if (!mod.tool.empty() || !mod.built.empty())
field(fonts, "BUILT WITH", mod.tool + (mod.built.empty() ? "" : (mod.tool.empty() ? "" : ", ") + mod.built));
std::string levels;
for (const auto& level : mod.levels) {
const auto slash = level.rfind('/');
levels += (levels.empty() ? "" : "\n") + (slash == std::string::npos ? level : level.substr(slash + 1));
}
field(fonts, "MAPS", levels);
std::string parks;
for (const auto& map : mod.park_maps) parks += (parks.empty() ? "" : ", ") + map;
field(fonts, "PARKS", parks);
if (!mod.provides_layout && !mod.provides_levels && mod.park_maps.empty())
field(fonts, "NOTE", "This folder has no layout.toc or reskate-levels.json, so the game has nothing to load from it.");
ImGui::PopTextWrapPos();
ImGui::EndChild();
ImGui::SetCursorPosY(extent.y - S(24) - ImGui::GetFrameHeight());
bool enabled = entry.enabled;
if (ImGui::Checkbox("Loads when Skate starts", &enabled)) {
entry.enabled = enabled;
save(panel);
}
ImGui::SameLine(extent.x - S(28) - S(110));
if (ImGui::Button("CLOSE", ImVec2(S(110), 0))) close();
ImGui::EndPopup();
}
// ---------------------------------------------------------------- installing
// What a mod manager shows while it works: everything else dimmed, and one
// panel saying what is being downloaded.
void install_modal(const Fonts& fonts, ModsPanel& panel, ImVec2 size) {
std::string activity;
{
std::lock_guard lock(panel.mutex);
activity = panel.activity;
}
const float fraction = std::clamp(panel.progress.load(), 0.0f, 1.0f);
const auto time = static_cast<float>(ImGui::GetTime());
auto* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(ImVec2(0, 0), size, rgba(4, 6, 9, 0.72f));
const ImVec2 extent(S(540), S(206));
const ImVec2 origin((size.x - extent.x) * 0.5f, (size.y - extent.y) * 0.5f);
const ImVec2 end(origin.x + extent.x, origin.y + extent.y);
skate_theme::rough_rect(draw, origin, end, color::blue, 11, g_scale);
draw->AddText(fonts.tile, fonts.tile->FontSize, ImVec2(origin.x + S(26), origin.y + S(18)), color::ink, "INSTALLING");
if (!activity.empty())
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(origin.x + S(26), origin.y + S(70)),
rgba(0, 0, 0, 0.78f), activity.c_str(), nullptr, extent.x - S(52));
skate_theme::striped_bar(draw, ImVec2(origin.x + S(26), origin.y + S(118)),
ImVec2(end.x - S(26), origin.y + S(134)), fraction, time, g_scale);
draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(origin.x + S(26), origin.y + S(142)), rgba(0, 0, 0, 0.78f),
std::format("{:.0f}%", fraction * 100).c_str());
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.38f)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.52f)));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(rgba(0, 0, 0, 0.66f)));
ImGui::SetCursorScreenPos(ImVec2(end.x - S(26) - S(130), end.y - S(24) - S(34)));
if (ImGui::Button("CANCEL", ImVec2(S(130), S(34)))) panel.cancel = true;
ImGui::PopStyleColor(3);
}
} // namespace
void refresh_mods(Launcher& launcher, ModsPanel& panel) {
scan(panel, launcher.session());
refresh_listing(panel, ImGui::GetTime(), true);
panel.message.clear();
}
void scan(ModsPanel& panel, const launcher_app::Session& session) {
panel.root = launcher_mods::mods_root(session.paths.directory);
panel.list = mods::scan_mods(panel.root.parent_path());
@@ -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<thunderstore::Package> 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();
}
+232 -134
View File
@@ -232,15 +232,14 @@ std::vector<const ts::Package*> visible_packages(const Store& store, const ts::I
constexpr std::array<const char*, 5> 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<std::string> 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::size_t>(std::clamp(store.sort, 0, 4))])) {
for (int i = 0; i < static_cast<int>(sort_names.size()); ++i)
if (ImGui::Selectable(sort_names[static_cast<std::size_t>(i)], store.sort == i)) store.sort = i;
ImGui::EndCombo();
}
ImGui::SameLine();
ImGui::BeginDisabled(store.loading);
if (ImGui::Button("Refresh", ImVec2(refresh, 0))) refresh_mods(launcher, panel);
ImGui::EndDisabled();
// ------------------------------------------------ what is ticked
if (!store.picked.empty()) {
ImGui::Spacing();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(store.picked.size() == 1
? "1 mod selected" : std::format("{} mods selected", store.picked.size()).c_str());
ImGui::SameLine();
const float action = S(190);
ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - action - S(90) - S(8));
if (ImGui::Button("Clear", ImVec2(S(90), 0))) store.picked.clear();
ImGui::SameLine();
ImGui::BeginDisabled(installing);
push_primary_button();
if (ImGui::Button(store.picked.size() == 1 ? "INSTALL 1 MOD"
: std::format("INSTALL {} MODS", store.picked.size()).c_str(), ImVec2(action, 0))) {
std::vector<ts::Package> chosen;
for (const auto& name : store.picked)
for (const auto& package : store.packages)
if (package.full_name == name) chosen.push_back(package);
store.picked.clear();
start_store_install(panel, std::move(chosen));
}
pop_primary_button();
ImGui::EndDisabled();
}
ImGui::Spacing();
const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top));
const 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<int>(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<std::size_t>(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<int>(packages.size()), [&](int) { return row; }, [&](int index, float tall) {
const auto& package = *packages[static_cast<std::size_t>(index)];
const auto& version = package.latest();
const auto found = installed.find(ts::folder_for(package.full_name));
const bool update = ts::update_available(package, installed);
// Tools (Blender add-ons, utilities) are not game mods: their page has the download.
const bool tool = package.in_category("Tools") && !package.in_category("Mods");
bool ticked = picked(store, package.full_name);
ImGui::PushID(package.full_name.c_str());
auto* draw = ImGui::GetWindowDrawList();
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
// The row itself opens the overview; the widgets on it keep their clicks.
if (list_row("##row", width, tall, ticked)) {
store.selected = package.full_name;
store.overview = false;
}
const float right = start.x + width;
const float text_x = start.x + S(82);
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
+39 -31
View File
@@ -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<const char*, 4> names{"GAME", "DISPLAY", "KEYS", "ADVANCED"};
ImGui::BeginDisabled(ui.binding != 0);
for (int i = 0; i < static_cast<int>(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<std::size_t>(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<const char*, 4> names{"GAME", "DISPLAY", "KEYS", "ADVANCED"};
ImGui::BeginDisabled(binding);
for (int i = 0; i < static_cast<int>(names.size()); ++i)
if (nav_tile(fonts, rail_width, names[static_cast<std::size_t>(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();
}
+87
View File
@@ -0,0 +1,87 @@
#pragma once
#include <string>
#include <string_view>
// 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 459 KiB

+1 -1
View File
@@ -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")