diff --git a/res/rml/window.rcss b/res/rml/window.rcss index df429e2b50..9c5ae28d9d 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -411,6 +411,10 @@ icon.verifying { decorator: text("" center center); } +icon.download { + decorator: text("" center center); +} + icon.celebration { decorator: text("" center center); } @@ -631,7 +635,7 @@ modal-content pane > * { } window.modal.install-queue { - max-height: 720dp; + max-height: 768dp; } window.modal.drop-install { @@ -643,19 +647,62 @@ window.modal.drop-install package-row { } window.modal.install-queue modal-body { - font-size: var(--font-size-sm); - color: rgba(var(--color-text-rgb), 60%); + display: none; +} + +window.modal.install-queue modal-content pane { + gap: 0; + padding-right: 14dp; + padding-bottom: 6dp; } package-row { display: flex; - flex-direction: column; + flex-direction: row; + align-items: flex-start; position: relative; width: 100%; - gap: var(--space-2xs); - padding: var(--space-sm) 78dp var(--space-sm) 0; + gap: 10dp; + padding: var(--space-md) 0; border-bottom-width: 1dp; - border-bottom-color: rgba(var(--color-border-rgb), 25%); + border-bottom-color: rgba(var(--color-border-rgb), 30%); +} + +package-row-icon { + display: none; + flex: 0 0 36dp; + width: 36dp; + height: 36dp; + margin-top: var(--space-2xs); + overflow: hidden; + border-radius: var(--radius-panel); + background-color: rgba(var(--color-control-rgb), 45%); + color: rgba(var(--color-text-rgb), 45%); + font-family: var(--font-family-icons); + font-size: var(--font-size-4xl); + decorator: text("" center center); +} + +package-row-icon.visible { + display: block; +} + +package-row-icon.has-image { + background-color: transparent; +} + +package-row.paused package-row-icon, +package-row.retrying package-row-icon, +package-row.failed package-row-icon { + filter: grayscale(1); + opacity: 0.55; +} + +package-row-info { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-width: 0; } package-row-heading { @@ -663,25 +710,48 @@ package-row-heading { width: 100%; gap: var(--space-sm); align-items: center; + padding-bottom: 6dp; +} + +package-row-identity { + display: flex; + align-items: baseline; + flex: 1 1 auto; + min-width: 0; + gap: var(--space-xs); + overflow: hidden; } package-row-name { display: block; - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; font-weight: bold; + color: var(--color-white); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -package-row-state { +package-row-version { display: block; flex: 0 0 auto; font-size: var(--font-size-xs); + font-weight: normal; + color: rgba(var(--color-text-rgb), 50%); +} + +package-row-state { + display: block; + flex: 0 0 auto; + font-size: var(--font-size-sm); color: rgba(var(--color-text-rgb), 60%); } +package-row.downloading package-row-state { + color: var(--color-accent); +} + package-row.retrying package-row-state { color: var(--color-warning); } @@ -697,23 +767,43 @@ package-row.installed package-row-detail { } package-row progress { - height: 4dp; - margin: var(--space-2xs) 0; + width: 100%; + height: 6dp; + margin: 0 0 var(--space-sm) 0; + border-radius: 3dp; } package-row.failed progress fill { background-color: var(--color-error); } +package-row.paused progress fill { + background-color: rgba(var(--color-text-rgb), 35%); +} + +package-row.retrying progress fill { + background-color: rgba(var(--color-warning-rgb), 55%); +} + package-row.installed progress fill { background-color: var(--color-success); } +package-row-footer { + display: flex; + align-items: flex-start; + width: 100%; + min-width: 0; + gap: var(--space-sm); +} + package-row-detail { display: block; + flex: 1 1 0; min-width: 0; - font-size: var(--font-size-2xs); - color: rgba(var(--color-text-rgb), 48%); + font-size: var(--font-size-xs); + line-height: 1; + color: rgba(var(--color-text-rgb), 45%); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -721,16 +811,54 @@ package-row-detail { package-row-actions { display: flex; - position: absolute; - right: 0; - top: 22dp; - gap: var(--space-2xs); + flex: 0 0 auto; + gap: 6dp; } -package-row-actions button { - min-width: 34dp; - padding: 4dp 7dp; - font-size: var(--font-size-3xs); +package-row-actions button.package-row-icon-action { + align-items: center; + justify-content: center; + flex: 0 0 30dp; + width: 30dp; + min-width: 30dp; + height: 26dp; + padding: 0; + border-radius: 14dp; + font-size: var(--font-size-md); +} + +package-row-actions button.package-row-icon-action icon { + flex: 0 0 var(--font-size-md); + width: var(--font-size-md); + height: var(--font-size-md); + font-size: var(--font-size-md); + line-height: 1; +} + +package-row.installed { + align-items: center; +} + +package-row.installed package-row-icon { + margin-top: 0; +} + +package-row.installed package-row-info { + padding-right: 38dp; +} + +package-row.installed package-row-state { + display: none; +} + +package-row.installed package-row-actions { + position: absolute; + top: 17dp; + right: 0; +} + +package-row.installed package-row-actions button.package-row-icon-action { + opacity: 0.45; } verification-progress { diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index aa8ff4ad1b..5af8d4b874 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -22,6 +22,7 @@ #include "dusk/io.hpp" #include "dusk/mods/log_buffer.hpp" #include "dusk/mods/path.hpp" +#include "dusk/mods/queue.hpp" #include "dusk/mods/svc/config.hpp" #include "dusk/mods/svc/hook.hpp" #include "dusk/mods/svc/registry.hpp" @@ -1781,6 +1782,7 @@ void ModLoader::apply_pending_requests() { } const auto removedName = mod->metadata.name; + const auto removedId = mod->metadata.id; std::error_code error; if (!fs::remove(mod->modPath, error)) { complete_operation(uninstall->operation, false, @@ -1788,11 +1790,12 @@ void ModLoader::apply_pending_requests() { continue; } forget_mod(*mod); + queue::remove_by_mod_id(removedId); complete_operation(uninstall->operation); ui::push_toast({ .title = "Mod uninstalled", .content = removedName, - .duration = std::chrono::seconds{4}, + .duration = std::chrono::seconds{2}, }); continue; } diff --git a/src/dusk/mods/queue.cpp b/src/dusk/mods/queue.cpp index 69a3bb50ec..503daf1ad3 100644 --- a/src/dusk/mods/queue.cpp +++ b/src/dusk/mods/queue.cpp @@ -477,6 +477,7 @@ Item snapshot(const QueueItem& item) { .total = item.total, .message = item.message, .local = local_source(item) != nullptr, + .icon = item.request.icon, }; if (item.task) { result.completed = std::max(result.completed, item.task.progress().completed); @@ -553,10 +554,13 @@ void update() { if (item->state == State::Handoff && item->operation && item->operation->state != ModOperation::State::Pending) { - item = queueItems.erase(item); - } else { - ++item; + item->message = item->operation->message; + item->state = item->operation->state == ModOperation::State::Succeeded ? + State::Installed : + State::InstallFailed; + item->operation.reset(); } + ++item; } for (auto& item : queueItems) { @@ -678,7 +682,23 @@ void resume(std::string_view id) { void retry(std::string_view id) { auto* item = find_queue_item(id); - if (item == nullptr || item->state != State::Failed) { + if (item == nullptr) { + return; + } + if (item->state == State::InstallFailed) { + auto* mod = ModLoader::instance().find_mod(item->request.id); + if (mod != nullptr) { + if (mod->activation_failed()) { + item->message.clear(); + item->state = State::Handoff; + item->operation = ModLoader::instance().request_reactivate(item->request.id); + } else { + item->message.clear(); + item->state = State::Installed; + } + return; + } + } else if (item->state != State::Failed) { return; } remove_partial(*item); @@ -690,7 +710,7 @@ void retry(std::string_view id) { void cancel(std::string_view id) { auto* item = find_queue_item(id); - if (item == nullptr || item->state == State::Handoff) { + if (item == nullptr || item->state == State::Handoff || is_terminal(item->state)) { return; } if (item->task) { @@ -711,6 +731,19 @@ void cancel(std::string_view id) { item->state = State::Canceled; } +void clear(std::string_view id) { + const auto item = std::ranges::find( + queueItems, id, [](const QueueItem& candidate) { return std::string_view{candidate.key}; }); + if (item != queueItems.end() && is_terminal(item->state)) { + queueItems.erase(item); + } +} + +void remove_by_mod_id(std::string_view id) { + std::erase_if(queueItems, + [id](const QueueItem& item) { return std::string_view{item.request.id} == id; }); +} + void pause_all() { std::vector ids; for (const auto& item : queueItems) { @@ -726,7 +759,7 @@ void pause_all() { } void clear_finished() { - std::erase_if(queueItems, [](const QueueItem& item) { return item.state == State::Canceled; }); + std::erase_if(queueItems, [](const QueueItem& item) { return is_terminal(item.state); }); } } // namespace dusk::mods::queue diff --git a/src/dusk/mods/queue.hpp b/src/dusk/mods/queue.hpp index d3d0533682..807bbc1aa0 100644 --- a/src/dusk/mods/queue.hpp +++ b/src/dusk/mods/queue.hpp @@ -18,12 +18,19 @@ enum class State { Retrying, Verifying, Handoff, + Installed, + InstallFailed, Failed, Canceled, }; [[nodiscard]] constexpr bool is_terminal(State state) noexcept { - return state == State::Failed || state == State::Canceled; + return state == State::Installed || state == State::InstallFailed || state == State::Failed || + state == State::Canceled; +} + +[[nodiscard]] constexpr bool is_install_result(State state) noexcept { + return state == State::Installed || state == State::InstallFailed; } struct Url { @@ -38,11 +45,18 @@ struct LocalFile { using Source = std::variant; +struct Icon { + std::string url; + uint32_t width = 0; + uint32_t height = 0; +}; + struct Request { std::string id; std::string name; std::string version; Source source; + std::optional icon; }; struct Item { @@ -57,6 +71,7 @@ struct Item { std::string message; int retrySeconds = 0; bool local = false; + std::optional icon; }; /** Adds an install, replacing failed or canceled work for the same package ID. */ @@ -78,6 +93,8 @@ void pause(std::string_view id); void resume(std::string_view id); void retry(std::string_view id); void cancel(std::string_view id); +void clear(std::string_view id); +void remove_by_mod_id(std::string_view id); void pause_all(); void clear_finished(); diff --git a/src/dusk/ui/drop_install_modal.cpp b/src/dusk/ui/drop_install_modal.cpp index e21e63c77e..3df830202b 100644 --- a/src/dusk/ui/drop_install_modal.cpp +++ b/src/dusk/ui/drop_install_modal.cpp @@ -106,12 +106,14 @@ DropInstallModal::DropInstallModal(std::vector packages, PreparedTa auto& row = pane.add_child(); const auto name = package.metadata.name.empty() ? borealis::io::fs_path_to_string(package.path.filename()) : - fmt::format("{} {}", package.metadata.name, package.metadata.version); + package.metadata.name; + const auto version = + package.metadata.name.empty() ? std::string{} : package.metadata.version; const auto detail = package.metadata.author.empty() ? format_bytes(package.size) : fmt::format("{} · {}", package.metadata.author, format_bytes(package.size)); - row.set_package(name, package.status, detail, package.valid ? "queued" : "failed"); + row.set_package(name, version, package.status, detail, package.valid ? "queued" : "failed"); row.set_disabled(!package.valid); } } diff --git a/src/dusk/ui/mod_browser.cpp b/src/dusk/ui/mod_browser.cpp index fadb841289..db06a5b3a2 100644 --- a/src/dusk/ui/mod_browser.cpp +++ b/src/dusk/ui/mod_browser.cpp @@ -143,6 +143,21 @@ std::string image_source(const mods::catalog::Image& image, uint32_t preferredWi return image.sources.empty() ? std::string{} : image.sources.back().pngUrl; } +std::optional queue_icon(const std::optional& image) { + if (!image) { + return std::nullopt; + } + auto source = image_source(*image, 128); + if (source.empty()) { + return std::nullopt; + } + return mods::queue::Icon{ + .url = std::move(source), + .width = image->width, + .height = image->height, + }; +} + void set_image(Rml::Element* element, const mods::catalog::Image& image, uint32_t preferredWidth, std::string_view fit = "cover") { if (element == nullptr) { @@ -436,6 +451,7 @@ public: .sha256 = detail.download.sha256, .size = detail.download.size, }, + .icon = queue_icon(detail.mod.icon), } { mRoot->SetClass("catalog-install-action", true); mCaption = append(parent, "catalog-install-caption"); @@ -503,6 +519,9 @@ public: progress = 1.0f; disabled = true; break; + case Installed: + case InstallFailed: + break; case Failed: glyph = "\uE5D5"; label = queued->local ? "Retry package" : "Retry download"; @@ -571,7 +590,12 @@ public: private: std::optional matching_queue_item() const { auto item = mods::queue::find_by_mod_id(mRequest.id); - return item && item->version == mRequest.version ? item : std::nullopt; + if (!item || item->version != mRequest.version || + mods::queue::is_install_result(item->state)) + { + return std::nullopt; + } + return item; } void press() { diff --git a/src/dusk/ui/mods_window.cpp b/src/dusk/ui/mods_window.cpp index 63dad37d89..948cde76ed 100644 --- a/src/dusk/ui/mods_window.cpp +++ b/src/dusk/ui/mods_window.cpp @@ -110,8 +110,8 @@ public: auto* info = append(mRoot, "mod-entry-info"); auto* name = append(info, "mod-entry-name"); append_text(append(name, "mod-entry-name-text"), "Browse online mods"); - append_text(append(info, "mod-entry-sub"), "Dusklight catalog"); - append_text(append(info, "mod-entry-desc"), "Discover and install published mods."); + append_text( + append(info, "mod-entry-desc"), "Discover and install mods from the community."); on_nav_command([callback = std::move(onOpen)](Rml::Event&, NavCommand cmd) { if (cmd != NavCommand::Confirm) { @@ -290,7 +290,6 @@ void ModsWindow::build_content(Rml::Element* content) { mBrowserSelected = true; mSelectedMod = nullptr; mSelectedModId.clear(); - build_browser_detail(pane); mark_current_entry(); }); } @@ -311,7 +310,6 @@ void ModsWindow::build_content(Rml::Element* content) { mSelectedModId.clear(); if (borealis::http::available()) { mBrowserSelected = true; - build_browser_detail(detailPane); } mark_current_entry(); return; @@ -334,7 +332,6 @@ void ModsWindow::build_content(Rml::Element* content) { if (mBrowserSelected && mBrowserEntry != nullptr) { mSelectedMod = nullptr; mSelectedModId.clear(); - build_browser_detail(detailPane); } else { mSelectedMod = nullptr; if (!mSelectedModId.empty()) { @@ -353,12 +350,6 @@ void ModsWindow::build_content(Rml::Element* content) { mark_current_entry(); } -void ModsWindow::build_browser_detail(Pane& pane) { - pane.root()->RemoveAttribute("mod-id"); - append_text(append(pane.root(), "mod-title"), "Browse Mods"); - pane.add_text("Find published Dusklight mods in the online catalog."); -} - void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) { pane.root()->SetAttribute("mod-id", mod.metadata.id); pane.add_child( diff --git a/src/dusk/ui/mods_window.hpp b/src/dusk/ui/mods_window.hpp index f676045bc2..a444d1d0be 100644 --- a/src/dusk/ui/mods_window.hpp +++ b/src/dusk/ui/mods_window.hpp @@ -28,7 +28,6 @@ private: }; void build_content(Rml::Element* content); - void build_browser_detail(Pane& pane); void build_detail(Pane& pane, mods::LoadedMod& mod); void confirm_uninstall(const mods::LoadedMod& mod); void refresh_snapshot(); diff --git a/src/dusk/ui/package_row.cpp b/src/dusk/ui/package_row.cpp index 6077150130..e4f9539cf7 100644 --- a/src/dusk/ui/package_row.cpp +++ b/src/dusk/ui/package_row.cpp @@ -25,6 +25,10 @@ const char* queue_state_class(mods::queue::State state) { return "installing"; case Handoff: return "installing"; + case Installed: + return "installed"; + case InstallFailed: + return "failed"; case Failed: return "failed"; case Canceled: @@ -50,8 +54,12 @@ std::string state_label(const mods::queue::Item& item) { return "Verifying"; case Handoff: return "Installing"; + case Installed: + return "Installed"; + case InstallFailed: + return "Failed"; case Failed: - return item.local ? "Package failed" : "Download failed"; + return "Failed"; case Canceled: return "Canceled"; } @@ -59,17 +67,24 @@ std::string state_label(const mods::queue::Item& item) { } PackageRow::PackageRow(Rml::Element* parent) : Component{create_row(parent)} { - auto* heading = append(mRoot, "package-row-heading"); - mName = append(heading, "package-row-name"); + mIcon = append(mRoot, "package-row-icon"); + auto* info = append(mRoot, "package-row-info"); + auto* heading = append(info, "package-row-heading"); + auto* identity = append(heading, "package-row-identity"); + mName = append(identity, "package-row-name"); + mVersion = append(identity, "package-row-version"); mState = append(heading, "package-row-state"); - mProgress = append(mRoot, "progress"); - mDetail = append(mRoot, "package-row-detail"); + mProgress = append(info, "progress"); + mFooter = append(info, "package-row-footer"); + mDetail = append(mFooter, "package-row-detail"); } -void PackageRow::set_package(std::string name, std::string status, std::string detail, - std::string stateClass, std::optional progress) { +void PackageRow::set_package(std::string name, std::string version, std::string status, + std::string detail, std::string stateClass, std::optional progress) { mRoot->SetClassNames(fmt::format("package-row {}", stateClass)); set_text_content(mName, name); + set_text_content(mVersion, version); + mVersion->SetProperty("display", version.empty() ? "none" : "block"); set_text_content(mState, status); set_text_content(mDetail, detail); if (progress) { @@ -80,9 +95,25 @@ void PackageRow::set_package(std::string name, std::string status, std::string d } } +void PackageRow::set_icon(std::string source) { + mIcon->SetClass("visible", true); + if (mIconSource == source) { + return; + } + mIconSource = std::move(source); + if (mIconSource.empty()) { + mIcon->RemoveProperty("decorator"); + mIcon->SetClass("has-image", false); + return; + } + mIcon->SetProperty( + "decorator", fmt::format(R"(image("{}" cover center center))", escape(mIconSource))); + mIcon->SetClass("has-image", true); +} + Rml::Element* PackageRow::actions_root() { if (mActions == nullptr) { - mActions = append(mRoot, "package-row-actions"); + mActions = append(mFooter, "package-row-actions"); } return mActions; } diff --git a/src/dusk/ui/package_row.hpp b/src/dusk/ui/package_row.hpp index d9662559cc..198c352744 100644 --- a/src/dusk/ui/package_row.hpp +++ b/src/dusk/ui/package_row.hpp @@ -16,16 +16,21 @@ class PackageRow : public Component { public: explicit PackageRow(Rml::Element* parent); - void set_package(std::string name, std::string status, std::string detail, + void set_package(std::string name, std::string version, std::string status, std::string detail, std::string stateClass, std::optional progress = {}); + void set_icon(std::string source); Rml::Element* actions_root(); private: + Rml::Element* mIcon = nullptr; Rml::Element* mName = nullptr; + Rml::Element* mVersion = nullptr; Rml::Element* mState = nullptr; Rml::Element* mProgress = nullptr; Rml::Element* mDetail = nullptr; + Rml::Element* mFooter = nullptr; Rml::Element* mActions = nullptr; + std::string mIconSource; }; } // namespace dusk::ui diff --git a/src/dusk/ui/queue_window.cpp b/src/dusk/ui/queue_window.cpp index ebee779408..2cf95a3480 100644 --- a/src/dusk/ui/queue_window.cpp +++ b/src/dusk/ui/queue_window.cpp @@ -1,11 +1,14 @@ #include "queue_window.hpp" #include "button.hpp" +#include "dusk/mod_loader.hpp" #include "dusk/mods/queue.hpp" #include "fmt/format.h" #include "format.hpp" +#include "mod_texture_provider.hpp" #include "package_row.hpp" #include "pane.hpp" +#include "remote_texture_provider.hpp" #include #include @@ -17,12 +20,27 @@ namespace dusk::ui { namespace { +void set_icon_button(Button& button, const Rml::String& glyph, const Rml::String& label, + Rml::String& currentGlyph, Rml::String& currentLabel) { + if (currentGlyph != glyph) { + clear_children(button.root()); + append_text(append(button.root(), "icon"), glyph); + currentGlyph = glyph; + } + if (currentLabel != label) { + button.root()->SetAttribute("aria-label", label); + button.root()->SetAttribute("title", label); + currentLabel = label; + } +} + class QueueRow final : public PackageRow { public: QueueRow(Rml::Element* parent, std::string id) : PackageRow{parent}, mId{std::move(id)} { auto* actions = actions_root(); - auto pause = std::make_unique