builds fixes

This commit is contained in:
2026-08-27 16:44:42 +03:00
parent 168884127b
commit 08e0dc4965
10 changed files with 788 additions and 360 deletions
+214 -11
View File
@@ -48,6 +48,30 @@ Dialog {
buildsDialog.refreshStatus() 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) { function commit(fields) {
if (buildsDialog.loading || buildsDialog.editIndex < 0) if (buildsDialog.loading || buildsDialog.editIndex < 0)
return 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 { Connections {
@@ -176,14 +351,7 @@ Dialog {
anchors.fill: parent anchors.fill: parent
anchors.margins: -6 anchors.margins: -6
hoverEnabled: true hoverEnabled: true
onClicked: { onClicked: buildsDialog.askRemove(buildRow.index)
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))
}
} }
} }
} }
@@ -276,11 +444,46 @@ Dialog {
font.pixelSize: 11 font.pixelSize: 11
} }
VersionCatalogCombo { // Само поле только показывает выбор: версий около тысячи, и
// разбираться в них удобнее в отдельном окне с категориями.
Rectangle {
id: bdMinecraft id: bdMinecraft
property string selectedId: ""
width: parent.width width: parent.width
catalog: buildsDialog.backend.versionCatalog height: 36
onAboutToOpen: buildsDialog.backend.refreshVersionCatalog() 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": onSelectedIdChanged: buildsDialog.commit({"minecraftVersion":
bdMinecraft.selectedId}) bdMinecraft.selectedId})
} }
+1 -1
View File
@@ -44,7 +44,7 @@ qt_add_qml_module(appMinecraft_launcher
QML_FILES QML_FILES
Main.qml Main.qml
DarkCombo.qml DarkCombo.qml
VersionCatalogCombo.qml VersionPickerDialog.qml
LabelledField.qml LabelledField.qml
BuildsDialog.qml BuildsDialog.qml
LoaderRow.qml LoaderRow.qml
+25 -34
View File
@@ -42,35 +42,35 @@ Column {
return -1 return -1
} }
// Самая свежая стабильная сборка, а если стабильных нет — просто самая // Единственное место, где меняются entries и selectedVersion. Через него
// свежая. Список отсортирован новыми вперёд, поэтому хватает первого // проходят все три пути — кэш, ответ сети и заполнение из сборки, — потому
// совпадения. // что выбранная версия обязана существовать в списке под текущую версию
function preferredIndex() { // игры. Раньше проверял только путь из сети, и версия лоадера от прошлой
for (var i = 0; i < loaderRow.entries.length; ++i) { // версии игры доживала до сохранения сборки.
if (loaderRow.entries[i].recommended) function applyEntries(list) {
return i loaderRow.entries = list
}
for (var j = 0; j < loaderRow.entries.length; ++j) { // Список отсортирован новыми вперёд, поэтому максимально доступная
if (loaderRow.entries[j].stable) // версия для этой версии игры — всегда первая строка.
return j if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) {
} const fallback = list.length > 0 ? list[0].version : ""
return loaderRow.entries.length > 0 ? 0 : -1 const changedValue = loaderRow.selectedVersion !== fallback
loaderRow.selectedVersion = fallback
if (changedValue && !loaderRow.applying)
loaderRow.changed()
} }
function syncCombo() { combo.currentIndex = loaderRow.versionIndex(loaderRow.selectedVersion)
const index = loaderRow.versionIndex(loaderRow.selectedVersion)
combo.currentIndex = index
} }
function reload() { function reload() {
if (!box.checked || loaderRow.gameVersion === "") { if (!box.checked || loaderRow.gameVersion === "") {
loaderRow.entries = [] loaderRow.applyEntries([])
return return
} }
// Сначала из кэша — список появляется мгновенно, — потом обновление сети. // Сначала из кэша — список появляется мгновенно, — потом обновление сети.
loaderRow.entries = loaderRow.backend.loaderVersions(loaderRow.loaderKey, loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey,
loaderRow.gameVersion) loaderRow.gameVersion))
loaderRow.syncCombo()
loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion) loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion)
} }
@@ -78,17 +78,17 @@ Column {
function applyBuild(loader, loaderVersion) { function applyBuild(loader, loaderVersion) {
loaderRow.applying = true loaderRow.applying = true
box.checked = loader === loaderRow.loaderKey box.checked = loader === loaderRow.loaderKey
// Версию из сборки берём как пожелание: если под выбранную версию игры
// её нет, applyEntries() заменит её на максимально доступную.
loaderRow.selectedVersion = box.checked ? loaderVersion : "" loaderRow.selectedVersion = box.checked ? loaderVersion : ""
loaderRow.reload() loaderRow.reload()
loaderRow.applying = false loaderRow.applying = false
} }
onGameVersionChanged: { onGameVersionChanged: {
// Сборка лоадера привязана к версии игры — при её смене выбор // Сборка лоадера привязана к версии игры: под новой версией прежний
// сбрасывается, и список перезапрашивается под новую версию. // выбор недействителен, и список перезапрашивается заново.
loaderRow.entries = []
loaderRow.selectedVersion = "" loaderRow.selectedVersion = ""
combo.currentIndex = -1
loaderRow.reload() loaderRow.reload()
} }
@@ -97,16 +97,7 @@ Column {
function onLoaderVersionsChanged(key, game) { function onLoaderVersionsChanged(key, game) {
if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked) if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked)
return return
loaderRow.entries = loaderRow.backend.loaderVersions(key, game) loaderRow.applyEntries(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()
} }
} }
+14 -2
View File
@@ -161,9 +161,17 @@ Window {
anchors.bottomMargin: -218 anchors.bottomMargin: -218
rotation: 0 rotation: 0
editable: false editable: false
hoverEnabled: true
model: backend.profileNames model: backend.profileNames
// Пока список закрыт: открытый popup накрывает кнопку, и подсказка
// висела бы поверх собственного меню.
ToolTip.visible: profileBox.hovered && !profileBox.down
ToolTip.text: backend.profileNames.length === 0
? qsTr("Профили игрока — добавьте первый профиль")
: qsTr("Профиль игрока: %1").arg(profileBox.displayText)
contentItem: Text { contentItem: Text {
id: profileText id: profileText
text: profileBox.displayText text: profileBox.displayText
@@ -427,8 +435,12 @@ Window {
background: Image { background: Image {
anchors.fill: parent anchors.fill: parent
source: settingsButton.pressed ? "images/Options/Options_Pressed.svg" // Ассеты Options названы наоборот относительно Folder: зелёный
: settingsButton.hovered ? "images/Options/Options_active.svg" // #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" : "images/Options/Options_Idle.svg"
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
} }
-250
View File
@@ -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()
}
}
}
}
}
}
}
}
+413
View File
@@ -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()
}
+22
View File
@@ -352,6 +352,28 @@ void BuildSwitcher::fail(const QString &message, bool gameDirIntact)
emit failed(toId, message, 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 QString BuildSwitcher::interruptedSwitchWarning() const
+7
View File
@@ -39,6 +39,13 @@ public:
const QString &gameDir); const QString &gameDir);
void cancel(); void cancel();
// Сборку удалили: убираем её архив и запись о нём. Возвращает false, если
// папку архива не удалось удалить целиком.
bool forgetBuild(int buildId);
// Есть ли у сборки сохранённый архив (для текста предупреждения об удалении).
bool hasArchive(int buildId) const { return !archivePathOf(buildId).isEmpty(); }
// Незавершённое переключение с прошлого запуска. Возвращает пустую строку, // Незавершённое переключение с прошлого запуска. Возвращает пустую строку,
// если всё в порядке, иначе — текст для пользователя. // если всё в порядке, иначе — текст для пользователя.
QString interruptedSwitchWarning() const; QString interruptedSwitchWarning() const;
+89 -61
View File
@@ -371,14 +371,71 @@ void LauncherBackend::removeProfile(int index)
emit profilesChanged(); 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) void LauncherBackend::removeCustomBuild(int index)
{ {
if (index < 0 || index >= m_builds.size()) if (index < 0 || index >= m_builds.size())
return; 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(); 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 customBuildsChanged();
emit activeBuildChanged(); 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 int LauncherBackend::indexOfBuildId(int buildId) const
@@ -525,75 +582,46 @@ void LauncherBackend::rebuildCatalog() const
{ {
m_catalog.clear(); m_catalog.clear();
// Плоский список в порядке манифеста — новые версии сверху. Раскладку по
// категориям и отбор делает окно выбора версии: ему нужен один список,
// который можно фильтровать, а не готовая структура с разделителями.
const QList<RemoteVersionEntry> remote = m_manifest->versions(); const QList<RemoteVersionEntry> remote = m_manifest->versions();
QHash<QString, int> remoteIndex; const QStringList installed = VersionLoader::installedVersions(gameDir());
remoteIndex.reserve(remote.size()); const QSet<QString> installedSet(installed.cbegin(), installed.cend());
for (int i = 0; i < remote.size(); ++i)
remoteIndex.insert(remote.at(i).id, i);
const auto makeVersionRow = [](const QString &id, const QString &type, bool installed) { const auto category = [](const QString &type) {
return QVariantMap{{QStringLiteral("kind"), if (type == QLatin1String("release") || type == QLatin1String("snapshot")
installed ? QStringLiteral("installed") : QStringLiteral("remote")}, || type == QLatin1String("old_beta") || type == QLatin1String("old_alpha"))
{QStringLiteral("id"), id}, 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("type"), type},
{QStringLiteral("category"), category(type)},
{QStringLiteral("label"), id}, {QStringLiteral("label"), id},
{QStringLiteral("installed"), installed}, {QStringLiteral("installed"), isInstalled},
{QStringLiteral("selectable"), true},
{QStringLiteral("search"), (id + u' ' + type).toLower()}}; {QStringLiteral("search"), (id + u' ' + type).toLower()}};
}; };
// Установленные: сначала знакомые манифесту, свежие сверху, затем сборки m_catalog.reserve(remote.size() + installed.size());
// вроде fabric-loader-…, о которых манифест ничего не знает. QSet<QString> known;
QStringList installed = VersionLoader::installedVersions(gameDir()); known.reserve(remote.size());
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<QString> 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;
}
for (const RemoteVersionEntry &entry : remote) { for (const RemoteVersionEntry &entry : remote) {
if (installedSet.contains(entry.id)) known.insert(entry.id);
continue; m_catalog.append(makeVersionRow(entry.id, entry.type, installedSet.contains(entry.id)));
m_catalog.append(makeVersionRow(entry.id, entry.type, false));
} }
// Сборки модлоадеров манифест не знает — они попадают в «Прочие».
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));
} }
// ── Загрузка версий ──────────────────────────────────────────────────────── // ── Загрузка версий ────────────────────────────────────────────────────────
+2
View File
@@ -103,6 +103,8 @@ public:
Q_INVOKABLE QVariantMap profileAt(int index) const; Q_INVOKABLE QVariantMap profileAt(int index) const;
Q_INVOKABLE QVariantMap customBuildAt(int index) const; Q_INVOKABLE QVariantMap customBuildAt(int index) const;
Q_INVOKABLE void removeProfile(int index); Q_INVOKABLE void removeProfile(int index);
// Что именно потеряется при удалении — для текста предупреждения.
Q_INVOKABLE QVariantMap customBuildRemovalInfo(int index) const;
Q_INVOKABLE void removeCustomBuild(int index); Q_INVOKABLE void removeCustomBuild(int index);
// Докачать то, чего не хватает выбранной сборке. Отдельной кнопкой, потому // Докачать то, чего не хватает выбранной сборке. Отдельной кнопкой, потому