From 08e0dc4965c42a12831f4c5a71aabddd7204ea45 Mon Sep 17 00:00:00 2001 From: galeon Date: Thu, 27 Aug 2026 16:44:42 +0300 Subject: [PATCH] builds fixes --- BuildsDialog.qml | 225 ++++++++++++++++++++-- CMakeLists.txt | 2 +- LoaderRow.qml | 61 +++--- Main.qml | 16 +- VersionCatalogCombo.qml | 250 ------------------------ VersionPickerDialog.qml | 413 ++++++++++++++++++++++++++++++++++++++++ buildswitcher.cpp | 22 +++ buildswitcher.h | 7 + launcherbackend.cpp | 150 +++++++++------ launcherbackend.h | 2 + 10 files changed, 788 insertions(+), 360 deletions(-) delete mode 100644 VersionCatalogCombo.qml create mode 100644 VersionPickerDialog.qml diff --git a/BuildsDialog.qml b/BuildsDialog.qml index 37a1426..13cfa66 100644 --- a/BuildsDialog.qml +++ b/BuildsDialog.qml @@ -48,6 +48,30 @@ Dialog { buildsDialog.refreshStatus() } + // Удаление необратимо и уносит с собой архив сборки, поэтому сначала + // спрашиваем. Индекс запоминаем здесь: к моменту ответа строка списка под + // курсором может быть уже другой. + function askRemove(index) { + const info = buildsDialog.backend.customBuildRemovalInfo(index) + if (!info || info.name === undefined) + return + removeConfirm.buildIndex = index + removeConfirm.buildName = info.name + removeConfirm.hasArchive = info.hasArchive === true + removeConfirm.active = info.active === true + removeConfirm.lastOne = info.lastOne === true + removeConfirm.open() + } + + function performRemove(index) { + buildsDialog.backend.removeCustomBuild(index) + const left = buildsDialog.backend.customBuildNames.length + if (left === 0) + buildsDialog.editIndex = -1 + else + buildsDialog.selectBuild(Math.min(index, left - 1)) + } + function commit(fields) { if (buildsDialog.loading || buildsDialog.editIndex < 0) return @@ -76,6 +100,157 @@ Dialog { } } + Dialog { + id: removeConfirm + + property int buildIndex: -1 + property string buildName: "" + property bool hasArchive: false + property bool active: false + property bool lastOne: false + + modal: true + padding: 0 + width: 420 + parent: buildsDialog.parent + x: (buildsDialog.parent.width - width) / 2 + y: (buildsDialog.parent.height - height) / 2 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#1e1e1e" + radius: 10 + border.color: "#cc6666" + border.width: 1 + } + + header: Item { + implicitHeight: 52 + Text { + anchors.centerIn: parent + text: qsTr("Удалить сборку?") + color: "#ffffff" + font.pixelSize: 17 + font.bold: true + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#333333" + } + } + + contentItem: Column { + spacing: 10 + topPadding: 18 + bottomPadding: 18 + + Text { + x: 20 + width: parent.width - 40 + wrapMode: Text.Wrap + color: "#ffffff" + font.pixelSize: 13 + text: qsTr("Сборка «%1» будет удалена без возможности восстановления.") + .arg(removeConfirm.buildName) + } + + Text { + x: 20 + width: parent.width - 40 + visible: removeConfirm.hasArchive + wrapMode: Text.Wrap + color: "#cc6666" + font.pixelSize: 12 + text: qsTr("Вместе с ней удалится её архив: моды, конфиги и миры этой сборки восстановить будет нельзя.") + } + + Text { + x: 20 + width: parent.width - 40 + visible: removeConfirm.active && !removeConfirm.lastOne + wrapMode: Text.Wrap + color: "#cc6666" + font.pixelSize: 12 + text: qsTr("Сборка сейчас активна: содержимое .minecraft принадлежит ей и будет очищено, а на его место развернётся следующая сборка.") + } + + Text { + x: 20 + width: parent.width - 40 + visible: removeConfirm.lastOne + wrapMode: Text.Wrap + color: "#888888" + font.pixelSize: 12 + text: qsTr("Это последняя сборка. Содержимое .minecraft останется на месте.") + } + } + + footer: Item { + implicitHeight: 60 + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: "#333333" + } + Row { + anchors.centerIn: parent + spacing: 12 + + Button { + text: qsTr("Отмена") + width: 130; height: 36 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#6a8510" : "#91B315" + radius: 6 + } + onClicked: removeConfirm.close() + } + + Button { + text: qsTr("Удалить") + width: 130; height: 36 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#8a3a3a" : "#663333" + radius: 6 + border.color: "#cc6666" + border.width: 1 + } + onClicked: { + const index = removeConfirm.buildIndex + removeConfirm.close() + buildsDialog.performRemove(index) + } + } + } + } + + onClosed: removeConfirm.buildIndex = -1 + } + + VersionPickerDialog { + id: versionPicker + backend: buildsDialog.backend + parent: buildsDialog.parent + x: (buildsDialog.parent.width - width) / 2 + y: (buildsDialog.parent.height - height) / 2 + onVersionChosen: (versionId) => bdMinecraft.selectedId = versionId + } + // Установка версии или модлоадера меняет комплектность сборки — строка // состояния должна это заметить, не дожидаясь переоткрытия окна. Connections { @@ -176,14 +351,7 @@ Dialog { anchors.fill: parent anchors.margins: -6 hoverEnabled: true - onClicked: { - buildsDialog.backend.removeCustomBuild(buildRow.index) - const left = buildsDialog.backend.customBuildNames.length - if (left === 0) - buildsDialog.editIndex = -1 - else - buildsDialog.selectBuild(Math.min(buildRow.index, left - 1)) - } + onClicked: buildsDialog.askRemove(buildRow.index) } } } @@ -276,11 +444,46 @@ Dialog { font.pixelSize: 11 } - VersionCatalogCombo { + // Само поле только показывает выбор: версий около тысячи, и + // разбираться в них удобнее в отдельном окне с категориями. + Rectangle { id: bdMinecraft + property string selectedId: "" + width: parent.width - catalog: buildsDialog.backend.versionCatalog - onAboutToOpen: buildsDialog.backend.refreshVersionCatalog() + height: 36 + radius: 6 + color: "#2a2a2a" + border.color: versionFieldArea.containsMouse ? "#91B315" : "#444444" + border.width: 1 + + Text { + anchors.fill: parent + leftPadding: 10 + rightPadding: 110 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + color: bdMinecraft.selectedId === "" ? "#666666" : "#ffffff" + text: bdMinecraft.selectedId === "" ? qsTr("Выберите версию") + : bdMinecraft.selectedId + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Выбрать…") + color: "#91B315" + font.pixelSize: 12 + } + + MouseArea { + id: versionFieldArea + anchors.fill: parent + hoverEnabled: true + onClicked: versionPicker.openFor(bdMinecraft.selectedId) + } + onSelectedIdChanged: buildsDialog.commit({"minecraftVersion": bdMinecraft.selectedId}) } diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d70ed3..f84c59d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,7 +44,7 @@ qt_add_qml_module(appMinecraft_launcher QML_FILES Main.qml DarkCombo.qml - VersionCatalogCombo.qml + VersionPickerDialog.qml LabelledField.qml BuildsDialog.qml LoaderRow.qml diff --git a/LoaderRow.qml b/LoaderRow.qml index 9242e46..1c7374a 100644 --- a/LoaderRow.qml +++ b/LoaderRow.qml @@ -42,35 +42,35 @@ Column { return -1 } - // Самая свежая стабильная сборка, а если стабильных нет — просто самая - // свежая. Список отсортирован новыми вперёд, поэтому хватает первого - // совпадения. - function preferredIndex() { - for (var i = 0; i < loaderRow.entries.length; ++i) { - if (loaderRow.entries[i].recommended) - return i - } - for (var j = 0; j < loaderRow.entries.length; ++j) { - if (loaderRow.entries[j].stable) - return j - } - return loaderRow.entries.length > 0 ? 0 : -1 - } + // Единственное место, где меняются entries и selectedVersion. Через него + // проходят все три пути — кэш, ответ сети и заполнение из сборки, — потому + // что выбранная версия обязана существовать в списке под текущую версию + // игры. Раньше проверял только путь из сети, и версия лоадера от прошлой + // версии игры доживала до сохранения сборки. + function applyEntries(list) { + loaderRow.entries = list - function syncCombo() { - const index = loaderRow.versionIndex(loaderRow.selectedVersion) - combo.currentIndex = index + // Список отсортирован новыми вперёд, поэтому максимально доступная + // версия для этой версии игры — всегда первая строка. + if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) { + const fallback = list.length > 0 ? list[0].version : "" + const changedValue = loaderRow.selectedVersion !== fallback + loaderRow.selectedVersion = fallback + if (changedValue && !loaderRow.applying) + loaderRow.changed() + } + + combo.currentIndex = loaderRow.versionIndex(loaderRow.selectedVersion) } function reload() { if (!box.checked || loaderRow.gameVersion === "") { - loaderRow.entries = [] + loaderRow.applyEntries([]) return } // Сначала из кэша — список появляется мгновенно, — потом обновление сети. - loaderRow.entries = loaderRow.backend.loaderVersions(loaderRow.loaderKey, - loaderRow.gameVersion) - loaderRow.syncCombo() + loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey, + loaderRow.gameVersion)) loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion) } @@ -78,17 +78,17 @@ Column { function applyBuild(loader, loaderVersion) { loaderRow.applying = true box.checked = loader === loaderRow.loaderKey + // Версию из сборки берём как пожелание: если под выбранную версию игры + // её нет, applyEntries() заменит её на максимально доступную. loaderRow.selectedVersion = box.checked ? loaderVersion : "" loaderRow.reload() loaderRow.applying = false } onGameVersionChanged: { - // Сборка лоадера привязана к версии игры — при её смене выбор - // сбрасывается, и список перезапрашивается под новую версию. - loaderRow.entries = [] + // Сборка лоадера привязана к версии игры: под новой версией прежний + // выбор недействителен, и список перезапрашивается заново. loaderRow.selectedVersion = "" - combo.currentIndex = -1 loaderRow.reload() } @@ -97,16 +97,7 @@ Column { function onLoaderVersionsChanged(key, game) { if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked) return - loaderRow.entries = loaderRow.backend.loaderVersions(key, game) - if (loaderRow.selectedVersion === "" || loaderRow.versionIndex(loaderRow.selectedVersion) < 0) { - const preferred = loaderRow.preferredIndex() - if (preferred >= 0) { - loaderRow.selectedVersion = loaderRow.entries[preferred].version - if (!loaderRow.applying) - loaderRow.changed() - } - } - loaderRow.syncCombo() + loaderRow.applyEntries(loaderRow.backend.loaderVersions(key, game)) } } diff --git a/Main.qml b/Main.qml index 95983a9..6af8c23 100644 --- a/Main.qml +++ b/Main.qml @@ -161,9 +161,17 @@ Window { anchors.bottomMargin: -218 rotation: 0 editable: false + hoverEnabled: true model: backend.profileNames + // Пока список закрыт: открытый popup накрывает кнопку, и подсказка + // висела бы поверх собственного меню. + ToolTip.visible: profileBox.hovered && !profileBox.down + ToolTip.text: backend.profileNames.length === 0 + ? qsTr("Профили игрока — добавьте первый профиль") + : qsTr("Профиль игрока: %1").arg(profileBox.displayText) + contentItem: Text { id: profileText text: profileBox.displayText @@ -427,8 +435,12 @@ Window { background: Image { anchors.fill: parent - source: settingsButton.pressed ? "images/Options/Options_Pressed.svg" - : settingsButton.hovered ? "images/Options/Options_active.svg" + // Ассеты Options названы наоборот относительно Folder: зелёный + // #637a10 лежит в Options_Pressed.svg, а Options_active.svg — + // тёмный, как Idle. Берём их по цвету, а не по имени, чтобы + // наведение выглядело так же, как у кнопки папки модов. + source: settingsButton.pressed ? "images/Options/Options_active.svg" + : settingsButton.hovered ? "images/Options/Options_Pressed.svg" : "images/Options/Options_Idle.svg" fillMode: Image.PreserveAspectFit } diff --git a/VersionCatalogCombo.qml b/VersionCatalogCombo.qml deleted file mode 100644 index e798a9c..0000000 --- a/VersionCatalogCombo.qml +++ /dev/null @@ -1,250 +0,0 @@ -import QtQuick -import QtQuick.Controls 2.15 - -// Выпадающий список версий: сверху установленные, ниже через разделитель -// весь каталог Mojang, сверху поле поиска. -// -// Это Item с Popup, а не ComboBox: editable-ComboBox привязывает поле ввода -// к currentText и запускает своё автодополнение, а при активном фильтре -// currentIndex перестаёт указывать в исходную модель. -Item { - id: vcombo - - property var catalog: [] - property string selectedId: "" - property string placeholder: qsTr("Выберите версию") - property string filterText: "" - signal aboutToOpen() - - implicitHeight: 36 - height: implicitHeight - - // Фильтр по номеру версии. ~900 строк за нажатие — доли миллисекунды, - // а делегаты ListView создаёт только для видимых строк. - function filterEntries(needle) { - const query = needle.trim().toLowerCase() - if (query === "") - return vcombo.catalog - const out = [] - for (var i = 0; i < vcombo.catalog.length; ++i) { - const entry = vcombo.catalog[i] - if (entry.selectable === false) - continue - if (entry.search.indexOf(query) !== -1) - out.push(entry) - } - return out - } - - readonly property var visibleEntries: vcombo.filterEntries(vcombo.filterText) - - function openPopup() { - vcombo.aboutToOpen() - vcombo.filterText = "" - vcomboFilter.text = "" - vcomboPopup.open() - vcomboFilter.forceActiveFocus() - } - - Rectangle { - id: vcomboField - anchors.fill: parent - color: "#2a2a2a" - radius: 6 - border.color: vcomboPopup.opened ? "#91B315" : "#444444" - border.width: 1 - - Text { - anchors.fill: parent - leftPadding: 10 - rightPadding: 32 - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - color: vcombo.selectedId === "" ? "#666666" : "#ffffff" - text: vcombo.selectedId === "" ? vcombo.placeholder : vcombo.selectedId - } - - // Та же стрелка, что у остальных списков окна. - Image { - width: 10; height: 10 - x: vcomboField.width - width - 12 - y: (vcomboField.height - height) / 2 - source: vcomboPopup.opened ? "images/Profile_Box/Asset_23.svg" - : "images/Profile_Box/Asset_24.svg" - rotation: 180 - sourceSize.width: 10; sourceSize.height: 10 - fillMode: Image.PreserveAspectFit - } - - MouseArea { - anchors.fill: parent - onClicked: vcombo.openPopup() - } - } - - Popup { - id: vcomboPopup - y: vcombo.height + 2 - width: vcombo.width - padding: 1 - height: 44 + Math.min(vcomboList.contentHeight, 240) - - background: Rectangle { - color: "#2a2a2a" - radius: 6 - border.color: "#444444" - border.width: 1 - } - - contentItem: Item { - TextField { - id: vcomboFilter - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: 4 - height: 30 - placeholderText: qsTr("Поиск версии…") - color: "#ffffff" - placeholderTextColor: "#666666" - background: Rectangle { - color: "#232323" - radius: 5 - border.color: vcomboFilter.activeFocus ? "#91B315" : "#444444" - border.width: 1 - } - onTextChanged: { - vcombo.filterText = text - vcomboList.currentIndex = vcomboList.firstSelectable(0, 1) - } - Keys.onEscapePressed: vcomboPopup.close() - Keys.onDownPressed: vcomboList.step(1) - Keys.onUpPressed: vcomboList.step(-1) - Keys.onReturnPressed: vcomboList.acceptCurrent() - Keys.onEnterPressed: vcomboList.acceptCurrent() - } - - ListView { - id: vcomboList - anchors.top: vcomboFilter.bottom - anchors.topMargin: 4 - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - clip: true - model: vcombo.visibleEntries - ScrollIndicator.vertical: ScrollIndicator {} - - // Разделитель и служебные строки клавишами пропускаем. - function firstSelectable(from, delta) { - for (var i = from; i >= 0 && i < count; i += delta) { - if (model[i].selectable !== false) - return i - } - return -1 - } - function step(delta) { - const next = firstSelectable(currentIndex + delta, delta) - if (next >= 0) { - currentIndex = next - positionViewAtIndex(next, ListView.Contain) - } - } - function acceptCurrent() { - if (currentIndex < 0 || currentIndex >= count) - return - const entry = model[currentIndex] - if (entry.selectable === false) - return - vcombo.selectedId = entry.id - vcomboPopup.close() - } - - delegate: Item { - id: vcomboRow - required property var modelData - required property int index - - width: vcomboList.width - height: modelData.kind === "separator" ? 26 - : modelData.kind === "status" ? 30 : 34 - - // Разделитель между установленными и каталогом. - Item { - anchors.fill: parent - visible: vcomboRow.modelData.kind === "separator" - - Rectangle { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: 8 - height: 1 - color: "#3a3a3a" - } - Rectangle { - anchors.centerIn: parent - color: "#2a2a2a" - width: separatorLabel.implicitWidth + 12 - height: 18 - Text { - id: separatorLabel - anchors.centerIn: parent - text: vcomboRow.modelData.label - color: "#888888" - font.pixelSize: 10 - } - } - } - - // «Загрузка списка…» или сообщение о недоступности. - Text { - anchors.centerIn: parent - visible: vcomboRow.modelData.kind === "status" - text: vcomboRow.modelData.label - color: "#888888" - font.pixelSize: 11 - font.italic: true - } - - Rectangle { - anchors.fill: parent - visible: vcomboRow.modelData.selectable !== false - color: (vcomboRowArea.containsMouse || vcomboList.currentIndex === vcomboRow.index) - ? "#3a3a3a" : "#2a2a2a" - - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - width: parent.width - 100 - elide: Text.ElideRight - text: vcomboRow.modelData.label - color: "#ffffff" - font.pixelSize: 12 - } - Text { - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: vcomboRow.modelData.installed ? qsTr("установлена") - : vcomboRow.modelData.type - color: vcomboRow.modelData.installed ? "#91B315" : "#666666" - font.pixelSize: 10 - } - - MouseArea { - id: vcomboRowArea - anchors.fill: parent - hoverEnabled: true - onClicked: { - vcombo.selectedId = vcomboRow.modelData.id - vcomboPopup.close() - } - } - } - } - } - } - } -} diff --git a/VersionPickerDialog.qml b/VersionPickerDialog.qml new file mode 100644 index 0000000..43643ce --- /dev/null +++ b/VersionPickerDialog.qml @@ -0,0 +1,413 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 2.15 + +// Выбор версии Minecraft отдельным окном: слева категории, справа сами версии +// с поиском сверху. +// +// Раньше это был выпадающий список на всю тысячу версий: найти в нём бету 1.7 +// можно было только поиском по точному номеру. Категории делят список на +// обозримые части, а поиск работает внутри выбранной. +Dialog { + id: picker + + required property var backend + + // Версия, с которой окно открылось: по «Отмене» выбор к ней и возвращается. + property string selectedId: "" + property string category: "release" + property string filterText: "" + property bool installedOnly: false + + // Выбранная версия подтверждена кнопкой или двойным щелчком. Имя не + // accepted(): такой сигнал у Dialog уже есть, и переопределить его нельзя. + signal versionChosen(string versionId) + + modal: true + padding: 0 + width: 720 + height: 480 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#1e1e1e" + radius: 10 + border.color: "#91B315" + border.width: 1 + } + + readonly property var categories: [ + { key: "release", title: qsTr("Релизы") }, + { key: "snapshot", title: qsTr("Снапшоты") }, + { key: "old_beta", title: qsTr("Беты") }, + { key: "old_alpha", title: qsTr("Альфы") }, + { key: "other", title: qsTr("Прочие") } + ] + + // Каталог приходит из C++ уже отсортированным (новые сверху), поэтому здесь + // только отбор — порядок не трогаем. + readonly property var visibleEntries: { + const query = picker.filterText.trim().toLowerCase() + const source = picker.backend.versionCatalog + const out = [] + for (var i = 0; i < source.length; ++i) { + const entry = source[i] + if (entry.category !== picker.category) + continue + if (picker.installedOnly && !entry.installed) + continue + if (query !== "" && entry.search.indexOf(query) === -1) + continue + out.push(entry) + } + return out + } + + function openFor(versionId) { + picker.selectedId = versionId + picker.filterText = "" + searchField.text = "" + // Открываемся на категории текущей версии, а не всегда на релизах. + picker.category = picker.categoryOf(versionId) + picker.backend.refreshVersionCatalog() + picker.open() + picker.revealSelected() + } + + function categoryOf(versionId) { + const source = picker.backend.versionCatalog + for (var i = 0; i < source.length; ++i) { + if (source[i].id === versionId) + return source[i].category + } + return "release" + } + + function indexOfSelected() { + const list = picker.visibleEntries + for (var i = 0; i < list.length; ++i) { + if (list[i].id === picker.selectedId) + return i + } + return -1 + } + + function revealSelected() { + const index = picker.indexOfSelected() + versionList.currentIndex = index + if (index >= 0) + versionList.positionViewAtIndex(index, ListView.Center) + } + + function acceptSelection() { + if (picker.selectedId === "") + return + picker.versionChosen(picker.selectedId) + picker.close() + } + + header: Item { + implicitHeight: 52 + Text { + anchors.centerIn: parent + text: qsTr("Версия Minecraft") + color: "#ffffff" + font.pixelSize: 17 + font.bold: true + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#333333" + } + } + + contentItem: RowLayout { + spacing: 0 + + // ── Категории ─────────────────────────────────────────────────── + Rectangle { + Layout.preferredWidth: 150 + Layout.fillHeight: true + color: "transparent" + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + + Repeater { + model: picker.categories + + delegate: Rectangle { + id: categoryRow + required property var modelData + + width: parent.width + height: 34 + radius: 5 + color: picker.category === categoryRow.modelData.key ? "#91B315" + : categoryArea.containsMouse ? "#2d2d2d" : "transparent" + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: categoryRow.modelData.title + color: "#ffffff" + font.pixelSize: 13 + } + + MouseArea { + id: categoryArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + picker.category = categoryRow.modelData.key + picker.revealSelected() + } + } + } + } + } + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: "#333333" + } + + // ── Версии ────────────────────────────────────────────────────── + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + TextField { + id: searchField + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 8 + height: 32 + placeholderText: qsTr("Поиск версии…") + color: "#ffffff" + placeholderTextColor: "#666666" + background: Rectangle { + color: "#232323" + radius: 5 + border.color: searchField.activeFocus ? "#91B315" : "#444444" + border.width: 1 + } + onTextChanged: { + picker.filterText = text + versionList.currentIndex = picker.indexOfSelected() + } + Keys.onEscapePressed: picker.close() + Keys.onReturnPressed: picker.acceptSelection() + Keys.onEnterPressed: picker.acceptSelection() + Keys.onDownPressed: versionList.step(1) + Keys.onUpPressed: versionList.step(-1) + } + + CheckBox { + id: installedOnlyBox + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + height: 28 + checked: picker.installedOnly + onToggled: { + picker.installedOnly = checked + picker.revealSelected() + } + + indicator: Rectangle { + implicitWidth: 16 + implicitHeight: 16 + x: 0 + y: (installedOnlyBox.height - height) / 2 + radius: 4 + color: installedOnlyBox.checked ? "#91B315" : "#2a2a2a" + border.color: installedOnlyBox.checked ? "#91B315" : "#444444" + border.width: 1 + + Text { + anchors.centerIn: parent + visible: installedOnlyBox.checked + text: "✓" + color: "#1e1e1e" + font.pixelSize: 12 + font.bold: true + } + } + contentItem: Text { + leftPadding: installedOnlyBox.indicator.width + 8 + text: qsTr("Только установленные") + color: "#aaaaaa" + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + } + } + + ListView { + id: versionList + anchors.top: searchField.bottom + anchors.topMargin: 6 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: installedOnlyBox.top + anchors.leftMargin: 8 + anchors.rightMargin: 8 + clip: true + spacing: 1 + model: picker.visibleEntries + ScrollIndicator.vertical: ScrollIndicator {} + + function step(delta) { + const next = versionList.currentIndex + delta + if (next < 0 || next >= versionList.count) + return + versionList.currentIndex = next + versionList.positionViewAtIndex(next, ListView.Contain) + picker.selectedId = picker.visibleEntries[next].id + } + + delegate: Rectangle { + id: versionRow + required property var modelData + required property int index + + width: versionList.width + height: 32 + radius: 4 + color: picker.selectedId === versionRow.modelData.id ? "#91B315" + : versionArea.containsMouse ? "#2d2d2d" : "transparent" + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: installedMark.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: versionRow.modelData.label + color: "#ffffff" + elide: Text.ElideRight + font.pixelSize: 13 + } + + Text { + id: installedMark + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + visible: versionRow.modelData.installed + text: "✓" + color: picker.selectedId === versionRow.modelData.id ? "#1e1e1e" : "#91B315" + font.pixelSize: 13 + font.bold: true + } + + MouseArea { + id: versionArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + picker.selectedId = versionRow.modelData.id + versionList.currentIndex = versionRow.index + } + onDoubleClicked: { + picker.selectedId = versionRow.modelData.id + picker.acceptSelection() + } + } + } + } + + // Пустая категория, ничего не нашлось или манифест ещё не пришёл. + Text { + anchors.centerIn: versionList + width: versionList.width - 20 + visible: picker.visibleEntries.length === 0 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: "#888888" + font.pixelSize: 12 + font.italic: true + text: picker.backend.catalogLoading + ? qsTr("Загрузка списка версий…") + : picker.backend.versionCatalog.length === 0 + ? qsTr("Список версий недоступен — проверьте соединение") + : qsTr("Ничего не найдено") + } + } + } + + footer: Item { + implicitHeight: 60 + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: "#333333" + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 300 + elide: Text.ElideRight + color: picker.selectedId === "" ? "#666666" : "#91B315" + font.pixelSize: 13 + text: picker.selectedId === "" ? qsTr("Версия не выбрана") : picker.selectedId + } + + Row { + anchors.right: parent.right + anchors.rightMargin: 20 + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + Button { + text: qsTr("Отмена") + width: 110; height: 36 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#444444" : "#333333" + radius: 6 + } + onClicked: picker.close() + } + + Button { + text: qsTr("Выбрать") + width: 110; height: 36 + enabled: picker.selectedId !== "" + opacity: enabled ? 1.0 : 0.45 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#6a8510" : "#91B315" + radius: 6 + } + onClicked: picker.acceptSelection() + } + } + } + + onOpened: searchField.forceActiveFocus() +} diff --git a/buildswitcher.cpp b/buildswitcher.cpp index 98215cf..94db612 100644 --- a/buildswitcher.cpp +++ b/buildswitcher.cpp @@ -352,6 +352,28 @@ void BuildSwitcher::fail(const QString &message, bool gameDirIntact) emit failed(toId, message, gameDirIntact); } +// ── Удаление сборки ──────────────────────────────────────────────────────── + +bool BuildSwitcher::forgetBuild(int buildId) +{ + if (buildId < 0) + return true; + + // Запись убираем в любом случае: даже если файл не удалось стереть, ссылаться + // на архив несуществующей сборки нельзя — иначе он «воскреснет» под новой + // сборкой с тем же id. + QJsonObject index = readIndex(); + QJsonObject builds = index.value(QStringLiteral("builds")).toObject(); + builds.remove(QString::number(buildId)); + index.insert(QStringLiteral("builds"), builds); + if (index.value(QStringLiteral("active")).toInt(-1) == buildId) + index.remove(QStringLiteral("active")); + writeIndex(index); + + QDir dir(buildDir(buildId)); + return !dir.exists() || dir.removeRecursively(); +} + // ── Незавершённое переключение ───────────────────────────────────────────── QString BuildSwitcher::interruptedSwitchWarning() const diff --git a/buildswitcher.h b/buildswitcher.h index 78b491d..c6cda93 100644 --- a/buildswitcher.h +++ b/buildswitcher.h @@ -39,6 +39,13 @@ public: const QString &gameDir); void cancel(); + // Сборку удалили: убираем её архив и запись о нём. Возвращает false, если + // папку архива не удалось удалить целиком. + bool forgetBuild(int buildId); + + // Есть ли у сборки сохранённый архив (для текста предупреждения об удалении). + bool hasArchive(int buildId) const { return !archivePathOf(buildId).isEmpty(); } + // Незавершённое переключение с прошлого запуска. Возвращает пустую строку, // если всё в порядке, иначе — текст для пользователя. QString interruptedSwitchWarning() const; diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 169afe1..78e47a2 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -371,14 +371,71 @@ void LauncherBackend::removeProfile(int index) emit profilesChanged(); } +QVariantMap LauncherBackend::customBuildRemovalInfo(int index) const +{ + if (index < 0 || index >= m_builds.size()) + return {}; + const CustomBuild &build = m_builds[index]; + const bool active = m_settings.value(QStringLiteral("activeBuildId")).toInt() == build.id + || m_builds.size() == 1; + return {{QStringLiteral("name"), build.name}, + {QStringLiteral("hasArchive"), m_switcher->hasArchive(build.id)}, + {QStringLiteral("active"), active}, + {QStringLiteral("lastOne"), m_builds.size() == 1}}; +} + void LauncherBackend::removeCustomBuild(int index) { if (index < 0 || index >= m_builds.size()) return; - m_builds.removeAt(index); + + if (m_switcher->isRunning() || m_busy || m_installer->isRunning() + || m_loaderInstaller->isRunning()) { + emit launchError(tr("Дождитесь окончания текущей операции")); + return; + } + if (m_launcher->isRunning()) { + emit launchError(tr("Сначала закройте игру")); + return; + } + + const CustomBuild removed = m_builds.takeAt(index); + const bool wasActive = m_settings.value(QStringLiteral("activeBuildId")).toInt() == removed.id + || m_builds.isEmpty(); saveCustomBuilds(); + + // Раньше удалялась только запись, а архив сборки оставался лежать в + // galeonLauncher/builds вместе со ссылкой на него в index.json. + if (!m_switcher->forgetBuild(removed.id)) { + emit launchError(tr("Сборка «%1» удалена, но её архив в %2 стереть не удалось") + .arg(removed.name, + QDir::toNativeSeparators(LauncherPaths::buildStorageDir()))); + } + emit customBuildsChanged(); emit activeBuildChanged(); + + if (!wasActive) + return; + + // Удалили активную сборку: в .minecraft лежит её содержимое, а активной + // становится другая. Оставить как есть нельзя — при следующем переключении + // чужое содержимое уехало бы в архив новой сборки поверх её собственного. + m_settings.remove(QStringLiteral("activeBuildId")); + saveSettings(); + + if (m_builds.isEmpty()) { + emit activeBuildChanged(); + return; + } + + const CustomBuild &next = m_builds.first(); + emit launchProgress(tr("Активна сборка «%1»…").arg(next.name)); + // from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе + // с ней. Дальше обычная очистка и распаковка новой активной сборки. + m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir()); + emit switchChanged(); + emit busyChanged(); } int LauncherBackend::indexOfBuildId(int buildId) const @@ -525,75 +582,46 @@ void LauncherBackend::rebuildCatalog() const { m_catalog.clear(); + // Плоский список в порядке манифеста — новые версии сверху. Раскладку по + // категориям и отбор делает окно выбора версии: ему нужен один список, + // который можно фильтровать, а не готовая структура с разделителями. const QList remote = m_manifest->versions(); - QHash remoteIndex; - remoteIndex.reserve(remote.size()); - for (int i = 0; i < remote.size(); ++i) - remoteIndex.insert(remote.at(i).id, i); + const QStringList installed = VersionLoader::installedVersions(gameDir()); + const QSet installedSet(installed.cbegin(), installed.cend()); - const auto makeVersionRow = [](const QString &id, const QString &type, bool installed) { - return QVariantMap{{QStringLiteral("kind"), - installed ? QStringLiteral("installed") : QStringLiteral("remote")}, - {QStringLiteral("id"), id}, + const auto category = [](const QString &type) { + if (type == QLatin1String("release") || type == QLatin1String("snapshot") + || type == QLatin1String("old_beta") || type == QLatin1String("old_alpha")) + return type; + return QStringLiteral("other"); + }; + + const auto makeVersionRow = [&category](const QString &id, const QString &type, bool isInstalled) { + return QVariantMap{{QStringLiteral("id"), id}, {QStringLiteral("type"), type}, + {QStringLiteral("category"), category(type)}, {QStringLiteral("label"), id}, - {QStringLiteral("installed"), installed}, - {QStringLiteral("selectable"), true}, + {QStringLiteral("installed"), isInstalled}, {QStringLiteral("search"), (id + u' ' + type).toLower()}}; }; - // Установленные: сначала знакомые манифесту, свежие сверху, затем сборки - // вроде fabric-loader-…, о которых манифест ничего не знает. - QStringList installed = VersionLoader::installedVersions(gameDir()); - std::stable_sort(installed.begin(), installed.end(), - [&remoteIndex, &remote](const QString &a, const QString &b) { - const auto ia = remoteIndex.constFind(a); - const auto ib = remoteIndex.constFind(b); - const bool knownA = ia != remoteIndex.constEnd(); - const bool knownB = ib != remoteIndex.constEnd(); - if (knownA != knownB) - return knownA; - if (knownA) - return ia.value() < ib.value(); // манифест уже отсортирован - return a < b; - }); - - QSet installedSet; - for (const QString &id : std::as_const(installed)) { - const auto it = remoteIndex.constFind(id); - const QString type = it != remoteIndex.constEnd() ? remote.at(it.value()).type : QString(); - installedSet.insert(id); - m_catalog.append(makeVersionRow(id, type, true)); - } - - m_catalog.append(QVariantMap{{QStringLiteral("kind"), QStringLiteral("separator")}, - {QStringLiteral("id"), QString()}, - {QStringLiteral("type"), QString()}, - {QStringLiteral("label"), tr("Все версии Minecraft")}, - {QStringLiteral("installed"), false}, - {QStringLiteral("selectable"), false}, - {QStringLiteral("search"), QString()}}); - - if (remote.isEmpty()) { - m_catalog.append( - QVariantMap{{QStringLiteral("kind"), QStringLiteral("status")}, - {QStringLiteral("id"), QString()}, - {QStringLiteral("type"), QString()}, - {QStringLiteral("label"), - m_manifest->isRefreshing() - ? tr("Загрузка списка версий…") - : tr("Список версий недоступен — проверьте соединение")}, - {QStringLiteral("installed"), false}, - {QStringLiteral("selectable"), false}, - {QStringLiteral("search"), QString()}}); - return; - } - + m_catalog.reserve(remote.size() + installed.size()); + QSet known; + known.reserve(remote.size()); for (const RemoteVersionEntry &entry : remote) { - if (installedSet.contains(entry.id)) - continue; - m_catalog.append(makeVersionRow(entry.id, entry.type, false)); + known.insert(entry.id); + m_catalog.append(makeVersionRow(entry.id, entry.type, installedSet.contains(entry.id))); } + + // Сборки модлоадеров манифест не знает — они попадают в «Прочие». + QStringList unknown; + for (const QString &id : installed) { + if (!known.contains(id)) + unknown << id; + } + std::sort(unknown.begin(), unknown.end()); + for (const QString &id : std::as_const(unknown)) + m_catalog.append(makeVersionRow(id, QString(), true)); } // ── Загрузка версий ──────────────────────────────────────────────────────── diff --git a/launcherbackend.h b/launcherbackend.h index 1e98c73..d18b556 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -103,6 +103,8 @@ public: Q_INVOKABLE QVariantMap profileAt(int index) const; Q_INVOKABLE QVariantMap customBuildAt(int index) const; Q_INVOKABLE void removeProfile(int index); + // Что именно потеряется при удалении — для текста предупреждения. + Q_INVOKABLE QVariantMap customBuildRemovalInfo(int index) const; Q_INVOKABLE void removeCustomBuild(int index); // Докачать то, чего не хватает выбранной сборке. Отдельной кнопкой, потому