import QtQuick import QtQuick.Layouts 2.15 import QtQuick.Controls 2.15 import Minecraft_launcher Window { id: window width: 1280 height: 720 visible: true flags: Qt.Window title: qsTr("Minecraft Launcher") // ── Backend ──────────────────────────────────────────────────────────── LauncherBackend { id: backend onLaunched: (profileName, versionName, serverUrl) => { window.showToast(qsTr("Запущено: %1 — %2").arg(profileName).arg(versionName), "#4b7a1f") } onLaunchProgress: (message) => window.showToast(message, "#3a5a8c", 0) onLaunchError: (message) => window.showToast(message, "#cc3333", 8000) onTwoFactorRequired: (profileName) => { twoFactorDialog.profileName = profileName twoFactorCode.text = "" twoFactorDialog.open() } onGameFinished: (exitCode, crashed) => { window.showToast(crashed || exitCode !== 0 ? qsTr("Игра завершилась с ошибкой (код %1)").arg(exitCode) : qsTr("Игра закрыта"), crashed || exitCode !== 0 ? "#cc3333" : "#555555") } onGameOutput: (line) => console.log(line) } // Единая всплывающая плашка: сообщения о ходе запуска, ошибки, статус игры. // timeout === 0 — держим до следующего сообщения. function showToast(text, color, timeout) { toastLabel.text = text toastBackground.color = color toastTimer.stop() if (timeout === undefined) timeout = 4000 if (timeout > 0) { toastTimer.interval = timeout toastTimer.restart() } } function formatMb(bytes) { return (bytes / 1048576).toFixed(1) } // ── Background ───────────────────────────────────────────────────────── Image { id: image opacity: 0.296 anchors.fill: parent source: "images/GovuztTW8AAHqBf.jpeg" fillMode: Image.Stretch } // ── Status toast ─────────────────────────────────────────────────────── Rectangle { id: toastBackground visible: toastLabel.text !== "" anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: downloadPanel.visible ? 110 : 40 width: Math.min(window.width - 80, toastLabel.implicitWidth + 32) height: toastLabel.implicitHeight + 20 radius: 8 color: "#cc3333" Text { id: toastLabel anchors.centerIn: parent width: parent.width - 32 color: "#ffffff" font.pixelSize: 14 horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap } Timer { id: toastTimer interval: 4000 onTriggered: toastLabel.text = "" } } // ── Download panel ───────────────────────────────────────────────────── // Левый нижний угол: не задевает кнопку запуска, плашку по центру и // кнопки папки с настройками. Rectangle { id: downloadPanel visible: backend.downloading anchors.left: parent.left anchors.bottom: parent.bottom anchors.leftMargin: 24 anchors.bottomMargin: 24 width: 320 height: 72 radius: 8 color: "#1e1e1e" opacity: 0.94 border.color: "#91B315" border.width: 1 Text { x: 12; y: 8 width: parent.width - 70 elide: Text.ElideRight color: "#ffffff" font.pixelSize: 12 font.bold: true text: qsTr("Загрузка %1").arg(backend.downloadVersion) } Text { anchors.right: parent.right anchors.rightMargin: 12 y: 8 color: "#91B315" font.pixelSize: 12 // Пока не известен общий объём, доля равна -1. text: backend.downloadProgress < 0 ? "…" : Math.round(backend.downloadProgress * 100) + "%" } Rectangle { id: dlTrack x: 12; y: 30 width: parent.width - 24 height: 6 radius: 3 color: "#2a2a2a" Rectangle { width: backend.downloadProgress > 0 ? dlTrack.width * backend.downloadProgress : 0 height: parent.height radius: 3 color: "#91B315" Behavior on width { NumberAnimation { duration: 120 } } } } Text { x: 12; y: 44 width: parent.width - 40 elide: Text.ElideMiddle color: "#888888" font.pixelSize: 10 text: backend.downloadBytesTotal > 0 ? qsTr("%1 — %2 / %3 МБ").arg(backend.downloadStatus) .arg(window.formatMb(backend.downloadBytesDone)) .arg(window.formatMb(backend.downloadBytesTotal)) : backend.downloadStatus } Text { anchors.right: parent.right anchors.bottom: parent.bottom anchors.rightMargin: 10 anchors.bottomMargin: 6 text: "✕" color: cancelDownloadArea.containsMouse ? "#cc6666" : "#666666" font.pixelSize: 12 MouseArea { id: cancelDownloadArea anchors.fill: parent anchors.margins: -6 hoverEnabled: true onClicked: backend.cancelDownload() } } } // ── Play button ──────────────────────────────────────────────────────── Button { id: button width: 335 height: 170 anchors.centerIn: parent hoverEnabled: false enabled: !backend.busy && !backend.gameRunning background: Image { id: buttonImage source: "images/Play_Button/Play_Active.svg" opacity: button.enabled ? 1.0 : 0.45 } onPressed: buttonImage.source = "images/Play_Button/Play_pressed.svg" onReleased: buttonImage.source = "images/Play_Button/Play_Active.svg" onClicked: backend.launchGame(profileBox.currentIndex, versionBox.currentIndex) } // ── Profile ComboBox ─────────────────────────────────────────────────── ComboBox { id: profileBox y: 451 width: 146 height: 42 anchors.left: button.right anchors.bottom: button.top anchors.leftMargin: -335 anchors.bottomMargin: -218 rotation: 0 editable: false model: backend.profileNames contentItem: Text { id: profileText text: profileBox.displayText font: profileBox.font color: "#ffffff" verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignLeft elide: Text.ElideRight anchors.fill: parent anchors.leftMargin: 30 anchors.rightMargin: 5 anchors.topMargin: 5 anchors.bottomMargin: 5 } indicator: Image { id: profileArrow width: 10; height: 10 visible: true anchors.left: parent.left anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter source: profileBox.down ? "images/Profile_Box/Asset_23.svg" : "images/Profile_Box/Asset_24.svg" rotation: 180 sourceSize.width: 10; sourceSize.height: 10 fillMode: Image.PreserveAspectFit autoTransform: true } background: Rectangle { implicitWidth: 120; implicitHeight: 40 color: profileBox.down ? "#91B315" : "#232323" radius: 6 border.color: profileBox.pressed ? "#ffffff" : "#232323" border.width: profileBox.visualFocus ? 2 : 1 } popup: Popup { y: profileBox.height - 1 width: profileBox.width padding: 0 // Высота = кнопка "+" (32) + элементы списка, не более 200 height: 32 + Math.min(profileItemList.contentHeight, 200) contentItem: Item { anchors.fill: parent // ── "+" button — всегда сверху ────────────────────────── Rectangle { id: profileAddBtnBg anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right height: 32 color: profileAddArea.containsPress ? "#2d2d2d" : "transparent" Text { anchors.centerIn: parent text: "+ Добавить профиль" color: "#91B315" font.pixelSize: 12 } MouseArea { id: profileAddArea anchors.fill: parent onClicked: { profileBox.popup.close() addProfileDialog.open() } } } // ── Добавленные профили — всегда ниже кнопки ─────────── ListView { id: profileItemList anchors.top: profileAddBtnBg.bottom anchors.left: parent.left anchors.right: parent.right height: Math.min(contentHeight, 200) clip: true model: backend.profileNames delegate: ItemDelegate { id: profileItem required property string modelData required property int index width: profileItemList.width height: 40 contentItem: Text { text: profileItem.modelData color: "#ffffff" font: profileBox.font elide: Text.ElideRight horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter leftPadding: 8 rightPadding: 62 } background: Rectangle { color: (profileBox.currentIndex === profileItem.index || profileItem.hovered) ? "#91B315" : "#232323" radius: 5 } onClicked: { profileBox.currentIndex = profileItem.index profileBox.popup.close() } // ── Карандаш — видна при наведении на строку ──── Image { id: profilePencil width: 16; height: 16 anchors.right: profileTrash.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter visible: profileItem.hovered opacity: profilePencilArea.containsMouse ? 1.0 : 0.7 source: "images/Pencil.svg" sourceSize.width: 16; sourceSize.height: 16 fillMode: Image.PreserveAspectFit MouseArea { id: profilePencilArea anchors.fill: parent anchors.margins: -6 hoverEnabled: true onClicked: editProfileDialog.openFor(profileItem.index) } } // ── Корзина — видна при наведении на строку ───── Image { id: profileTrash width: 16; height: 16 anchors.right: parent.right anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter visible: profileItem.hovered opacity: profileTrashArea.containsMouse ? 1.0 : 0.7 source: "images/Trash.svg" sourceSize.width: 16; sourceSize.height: 16 fillMode: Image.PreserveAspectFit MouseArea { id: profileTrashArea anchors.fill: parent anchors.margins: -6 hoverEnabled: true onClicked: backend.removeProfile(profileItem.index) } } } ScrollIndicator.vertical: ScrollIndicator { parent: profileItemList.parent anchors.top: profileItemList.top anchors.left: profileItemList.right anchors.bottom: profileItemList.bottom } } } background: Rectangle { color: "#232323" radius: 6 border.color: "#232323" } } } // ── Version ComboBox ─────────────────────────────────────────────────── ComboBox { id: versionBox x: 624 y: 451 width: 183 height: 42 anchors.right: button.right anchors.bottom: button.top anchors.rightMargin: 0 anchors.bottomMargin: -218 editable: false model: backend.versionNames indicator: Image { id: versionArrow width: 10; height: 10 visible: true anchors.left: parent.left anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter source: versionBox.down ? "images/Profile_Box/Asset_23.svg" : "images/Profile_Box/Asset_24.svg" rotation: 180 sourceSize.width: 10; sourceSize.height: 10 fillMode: Image.PreserveAspectFit autoTransform: true } contentItem: Text { id: versionText text: versionBox.displayText font: versionBox.font color: "#ffffff" verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignLeft elide: Text.ElideRight anchors.fill: parent anchors.leftMargin: 30 anchors.rightMargin: 5 anchors.topMargin: 5 anchors.bottomMargin: 5 } background: Rectangle { implicitWidth: 120; implicitHeight: 40 color: versionBox.down ? "#91B315" : "#232323" radius: 6 border.color: versionBox.pressed ? "#ffffff" : "#232323" border.width: versionBox.visualFocus ? 2 : 1 } popup: Popup { y: versionBox.height - 1 width: versionBox.width padding: 0 height: 32 + Math.min(versionItemList.contentHeight, 200) contentItem: Item { anchors.fill: parent // ── "+" button — всегда сверху ────────────────────────── Rectangle { id: versionAddBtnBg anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right height: 32 color: versionAddArea.containsPress ? "#2d2d2d" : "transparent" Text { anchors.centerIn: parent text: "+ Добавить версию" color: "#91B315" font.pixelSize: 12 } MouseArea { id: versionAddArea anchors.fill: parent onClicked: { versionBox.popup.close() addVersionDialog.open() } } } // ── Добавленные версии — всегда ниже кнопки ──────────── ListView { id: versionItemList anchors.top: versionAddBtnBg.bottom anchors.left: parent.left anchors.right: parent.right height: Math.min(contentHeight, 200) clip: true model: backend.versionNames delegate: ItemDelegate { id: versionItem required property string modelData required property int index width: versionItemList.width height: 40 contentItem: Text { text: versionItem.modelData color: "#ffffff" font: versionBox.font elide: Text.ElideRight horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter leftPadding: 8 rightPadding: 62 } background: Rectangle { color: (versionBox.currentIndex === versionItem.index || versionItem.hovered) ? "#91B315" : "#232323" radius: 5 } onClicked: { versionBox.currentIndex = versionItem.index versionBox.popup.close() } // ── Карандаш — видна при наведении на строку ──── Image { id: versionPencil width: 16; height: 16 anchors.right: versionTrash.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter visible: versionItem.hovered opacity: versionPencilArea.containsMouse ? 1.0 : 0.7 source: "images/Pencil.svg" sourceSize.width: 16; sourceSize.height: 16 fillMode: Image.PreserveAspectFit MouseArea { id: versionPencilArea anchors.fill: parent anchors.margins: -6 hoverEnabled: true onClicked: editVersionDialog.openFor(versionItem.index) } } // ── Корзина — видна при наведении на строку ───── Image { id: versionTrash width: 16; height: 16 anchors.right: parent.right anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter visible: versionItem.hovered opacity: versionTrashArea.containsMouse ? 1.0 : 0.7 source: "images/Trash.svg" sourceSize.width: 16; sourceSize.height: 16 fillMode: Image.PreserveAspectFit MouseArea { id: versionTrashArea anchors.fill: parent anchors.margins: -6 hoverEnabled: true onClicked: backend.removeVersion(versionItem.index) } } } ScrollIndicator.vertical: ScrollIndicator { parent: versionItemList.parent anchors.top: versionItemList.top anchors.left: versionItemList.right anchors.bottom: versionItemList.bottom } } } background: Rectangle { color: "#232323" radius: 6 border.color: "#232323" } } } // ── Open mods folder button ──────────────────────────────────────────── Button { id: folderButton width: 70 height: 40 anchors.left: versionBox.left anchors.bottom: profileBox.bottom anchors.leftMargin: -59 anchors.bottomMargin: -45 hoverEnabled: true background: Image { id: folderButtonImage anchors.fill: parent source: folderButton.pressed ? "images/Folder/Folder_Pressed.svg" : folderButton.hovered ? "images/Folder/Folder_Active.svg" : "images/Folder/Folder_Idle.svg" fillMode: Image.PreserveAspectFit } ToolTip.visible: hovered ToolTip.text: qsTr("Открыть папку с модами Minecraft") onClicked: backend.openMinecraftFolder() } // ── Settings button ──────────────────────────────────────────────────── Button { id: settingsButton width: 70 height: 40 anchors.left: folderButton.right anchors.verticalCenter: folderButton.verticalCenter anchors.leftMargin: 8 hoverEnabled: true background: Image { anchors.fill: parent source: settingsButton.pressed ? "images/Options/Options_Pressed.svg" : settingsButton.hovered ? "images/Options/Options_active.svg" : "images/Options/Options_Idle.svg" fillMode: Image.PreserveAspectFit } ToolTip.visible: hovered ToolTip.text: qsTr("Настройки запуска: Java, память, папка игры") onClicked: settingsDialog.load() } // ── Add Profile Dialog ───────────────────────────────────────────────── // Bug fix: header/footer must be Item (not Rectangle) with explicit // implicitHeight so Dialog correctly computes its own total height. // Rectangle.implicitHeight defaults to 0 regardless of height:. Dialog { id: addProfileDialog modal: true width: 320 x: (window.width - width) / 2 y: (window.height - height) / 2 padding: 0 background: Rectangle { color: "#1e1e1e" radius: 10 border.color: "#91B315" border.width: 1 } header: Item { implicitHeight: 52 // tells Dialog how tall the header is Text { anchors.centerIn: parent text: "Новый профиль" color: "#ffffff" font.pixelSize: 17 font.bold: true } Rectangle { anchors.bottom: parent.bottom width: parent.width height: 1 color: "#333333" } } contentItem: Column { spacing: 12 topPadding: 20 bottomPadding: 20 TextField { id: pfName x: 20 width: parent.width - 40 placeholderText: "Имя профиля" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: pfName.activeFocus ? "#91B315" : "#444444" border.width: 1 } } TextField { id: pfLogin x: 20 width: parent.width - 40 placeholderText: "Логин" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: pfLogin.activeFocus ? "#91B315" : "#444444" border.width: 1 } } TextField { id: pfPassword x: 20 width: parent.width - 40 enabled: pfAuth.currentIndex === 1 placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: pfPassword.activeFocus ? "#91B315" : "#444444" border.width: 1 } } DarkCombo { id: pfAuth x: 20 width: parent.width - 40 model: ["Офлайн (без пароля)", "Ely.by"] } } footer: Item { implicitHeight: 60 // tells Dialog how tall the footer is Rectangle { anchors.top: parent.top width: parent.width height: 1 color: "#333333" } Row { anchors.centerIn: parent spacing: 12 Button { text: "Отмена" 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: addProfileDialog.reject() } Button { text: "Добавить" width: 110; 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: addProfileDialog.accept() } } } onAccepted: { const name = pfName.text.trim() if (name !== "") { backend.addProfile(name, pfLogin.text.trim(), pfPassword.text, pfAuth.currentIndex === 1 ? "elyby" : "offline") profileBox.currentIndex = backend.profileNames.length - 1 } pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0 } onRejected: { pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0 } } // ── Add Version Dialog ───────────────────────────────────────────────── Dialog { id: addVersionDialog modal: true width: 320 x: (window.width - width) / 2 y: (window.height - height) / 2 padding: 0 background: Rectangle { color: "#1e1e1e" radius: 10 border.color: "#91B315" border.width: 1 } header: Item { implicitHeight: 52 Text { anchors.centerIn: parent text: "Новая версия" color: "#ffffff" font.pixelSize: 17 font.bold: true } Rectangle { anchors.bottom: parent.bottom width: parent.width height: 1 color: "#333333" } } contentItem: Column { spacing: 12 topPadding: 20 bottomPadding: 20 TextField { id: verName x: 20 width: parent.width - 40 placeholderText: "Название версии" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: verName.activeFocus ? "#91B315" : "#444444" border.width: 1 } } TextField { id: verServer x: 20 width: parent.width - 40 placeholderText: "Адрес сервера, host:port (необязательно)" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: verServer.activeFocus ? "#91B315" : "#444444" border.width: 1 } } // Установленные версии и весь каталог Mojang одним списком. VersionCatalogCombo { id: verVersion x: 20 width: parent.width - 40 catalog: backend.versionCatalog onAboutToOpen: backend.refreshVersionCatalog() } Text { x: 20 width: parent.width - 40 text: verVersion.selectedId === "" ? qsTr("Выберите версию из списка") : backend.isVersionInstalled(verVersion.selectedId) ? qsTr("Версия уже установлена в .minecraft") : qsTr("Версия будет загружена в .minecraft после сохранения") color: verVersion.selectedId !== "" && !backend.isVersionInstalled(verVersion.selectedId) ? "#91B315" : "#888888" font.pixelSize: 11 wrapMode: Text.Wrap } } footer: Item { implicitHeight: 60 Rectangle { anchors.top: parent.top width: parent.width height: 1 color: "#333333" } Row { anchors.centerIn: parent spacing: 12 Button { text: "Отмена" 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: addVersionDialog.reject() } Button { text: "Добавить" width: 110; 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: addVersionDialog.accept() } } } // Манифест прогреваем заранее — тогда список открывается мгновенно. onAboutToShow: { verVersion.selectedId = "" backend.refreshVersionCatalog() } onAccepted: { const name = verName.text.trim() if (name !== "") { backend.addVersion(name, verServer.text.trim(), verVersion.selectedId) versionBox.currentIndex = backend.versionNames.length - 1 } verName.text = ""; verServer.text = ""; verVersion.selectedId = "" } onRejected: { verName.text = ""; verServer.text = ""; verVersion.selectedId = "" } } // ── Edit Profile Dialog ──────────────────────────────────────────────── // Идентичен диалогу создания, но с предзаполненными данными выбранного // профиля. По нажатию ОК данные перезаписываются под тем же индексом. Dialog { id: editProfileDialog modal: true width: 320 x: (window.width - width) / 2 y: (window.height - height) / 2 padding: 0 property int editIndex: -1 function openFor(index) { const data = backend.profileAt(index) editIndex = index epName.text = data.name || "" epLogin.text = data.login || "" epPassword.text = data.password || "" epAuth.currentIndex = data.authType === "elyby" ? 1 : 0 profileBox.popup.close() open() } background: Rectangle { color: "#1e1e1e" radius: 10 border.color: "#91B315" border.width: 1 } header: Item { implicitHeight: 52 Text { anchors.centerIn: parent text: "Редактирование профиля" color: "#ffffff" font.pixelSize: 17 font.bold: true } Rectangle { anchors.bottom: parent.bottom width: parent.width height: 1 color: "#333333" } } contentItem: Column { spacing: 12 topPadding: 20 bottomPadding: 20 TextField { id: epName x: 20 width: parent.width - 40 placeholderText: "Имя профиля" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: epName.activeFocus ? "#91B315" : "#444444" border.width: 1 } } TextField { id: epLogin x: 20 width: parent.width - 40 placeholderText: "Логин" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: epLogin.activeFocus ? "#91B315" : "#444444" border.width: 1 } } TextField { id: epPassword x: 20 width: parent.width - 40 enabled: epAuth.currentIndex === 1 placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: epPassword.activeFocus ? "#91B315" : "#444444" border.width: 1 } } DarkCombo { id: epAuth x: 20 width: parent.width - 40 model: ["Офлайн (без пароля)", "Ely.by"] } } footer: Item { implicitHeight: 60 Rectangle { anchors.top: parent.top width: parent.width height: 1 color: "#333333" } Row { anchors.centerIn: parent spacing: 12 Button { text: "Отмена" 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: editProfileDialog.reject() } Button { text: "ОК" width: 110; 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: editProfileDialog.accept() } } } onAccepted: { const name = epName.text.trim() if (editIndex >= 0 && name !== "") backend.updateProfile(editIndex, name, epLogin.text.trim(), epPassword.text, epAuth.currentIndex === 1 ? "elyby" : "offline") editIndex = -1 epName.text = ""; epLogin.text = ""; epPassword.text = "" } onRejected: { editIndex = -1 epName.text = ""; epLogin.text = ""; epPassword.text = "" } } // ── Edit Version Dialog ──────────────────────────────────────────────── // Идентичен диалогу создания, но с предзаполненными данными выбранной // версии. По нажатию ОК данные перезаписываются под тем же индексом. Dialog { id: editVersionDialog modal: true width: 380 x: (window.width - width) / 2 y: (window.height - height) / 2 padding: 0 property int editIndex: -1 function openFor(index) { const data = backend.versionAt(index) editIndex = index evName.text = data.name || "" evServer.text = data.serverUrl || "" evVersion.selectedId = data.versionId || "" backend.refreshVersionCatalog() const problems = backend.checkInstallation(index) evStatus.text = problems.length === 0 ? qsTr("Всё на месте, версию можно запускать") : qsTr("Не хватает файлов (%1): %2").arg(problems.length).arg(problems[0]) versionBox.popup.close() open() } background: Rectangle { color: "#1e1e1e" radius: 10 border.color: "#91B315" border.width: 1 } header: Item { implicitHeight: 52 Text { anchors.centerIn: parent text: "Редактирование версии" color: "#ffffff" font.pixelSize: 17 font.bold: true } Rectangle { anchors.bottom: parent.bottom width: parent.width height: 1 color: "#333333" } } contentItem: Column { spacing: 12 topPadding: 20 bottomPadding: 20 TextField { id: evName x: 20 width: parent.width - 40 placeholderText: "Название версии" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: evName.activeFocus ? "#91B315" : "#444444" border.width: 1 } } TextField { id: evServer x: 20 width: parent.width - 40 placeholderText: "Адрес сервера, host:port (необязательно)" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: evServer.activeFocus ? "#91B315" : "#444444" border.width: 1 } } VersionCatalogCombo { id: evVersion x: 20 width: parent.width - 40 catalog: backend.versionCatalog onAboutToOpen: backend.refreshVersionCatalog() } // Показываем, чего не хватает в .minecraft именно для этой версии. Text { id: evStatus x: 20 width: parent.width - 40 color: text.indexOf("Всё на месте") === 0 ? "#8fbf5a" : "#cc6666" font.pixelSize: 11 wrapMode: Text.Wrap } } footer: Item { implicitHeight: 60 Rectangle { anchors.top: parent.top width: parent.width height: 1 color: "#333333" } Row { anchors.centerIn: parent spacing: 12 // Починка: докачать то, чего не хватает выбранной версии. Button { text: "Скачать" width: 110; height: 36 enabled: evVersion.selectedId !== "" && !backend.downloading opacity: enabled ? 1.0 : 0.45 contentItem: Text { text: parent.text color: "#ffffff" horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { color: parent.pressed ? "#444444" : "#333333" radius: 6 } onClicked: backend.installVersion(evVersion.selectedId) } Button { text: "Отмена" 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: editVersionDialog.reject() } Button { text: "ОК" width: 110; 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: editVersionDialog.accept() } } } onAccepted: { const name = evName.text.trim() if (editIndex >= 0 && name !== "") backend.updateVersion(editIndex, name, evServer.text.trim(), evVersion.selectedId) editIndex = -1 evName.text = ""; evServer.text = ""; evVersion.selectedId = "" } onRejected: { editIndex = -1 evName.text = ""; evServer.text = ""; evVersion.selectedId = "" } } // ── Two-factor Dialog ────────────────────────────────────────────────── // Ely.by отклоняет пароль с пометкой two factor — код добираем здесь и // продолжаем прерванный запуск. Dialog { id: twoFactorDialog modal: true width: 320 x: (window.width - width) / 2 y: (window.height - height) / 2 padding: 0 closePolicy: Popup.NoAutoClose property string profileName: "" background: Rectangle { color: "#1e1e1e" radius: 10 border.color: "#91B315" border.width: 1 } header: Item { implicitHeight: 52 Text { anchors.centerIn: parent text: "Двухфакторная аутентификация" color: "#ffffff" font.pixelSize: 15 font.bold: true } Rectangle { anchors.bottom: parent.bottom width: parent.width height: 1 color: "#333333" } } contentItem: Column { spacing: 12 topPadding: 20 bottomPadding: 20 Text { x: 20 width: parent.width - 40 text: qsTr("Введите код из приложения-аутентификатора для «%1»") .arg(twoFactorDialog.profileName) color: "#aaaaaa" font.pixelSize: 12 wrapMode: Text.Wrap } TextField { id: twoFactorCode x: 20 width: parent.width - 40 placeholderText: "000000" inputMethodHints: Qt.ImhDigitsOnly color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: twoFactorCode.activeFocus ? "#91B315" : "#444444" border.width: 1 } onAccepted: twoFactorDialog.accept() } } footer: Item { implicitHeight: 60 Rectangle { anchors.top: parent.top width: parent.width height: 1 color: "#333333" } Row { anchors.centerIn: parent spacing: 12 Button { text: "Отмена" 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: twoFactorDialog.reject() } Button { text: "Войти" width: 110; 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: twoFactorDialog.accept() } } } onAccepted: backend.submitTwoFactorCode(twoFactorCode.text) onRejected: backend.cancelPendingLaunch() } // ── Settings Dialog ──────────────────────────────────────────────────── Dialog { id: settingsDialog modal: true width: 460 x: (window.width - width) / 2 y: (window.height - height) / 2 padding: 0 function load() { const s = backend.settings() stGameDir.text = s.gameDir || "" stJavaPath.text = s.javaPath || "" stMinMemory.text = String(s.minMemoryMb) stMaxMemory.text = String(s.maxMemoryMb) stJvmArgs.text = s.jvmArgs || "" stWidth.text = String(s.windowWidth) stHeight.text = String(s.windowHeight) stFullscreen.checked = s.fullscreen === true stResolved.text = qsTr("Папка игры: %1").arg(s.resolvedGameDir) stJavaList.text = backend.detectedJava().join("\n") || qsTr("Java не найдена") open() } background: Rectangle { color: "#1e1e1e" radius: 10 border.color: "#91B315" border.width: 1 } header: Item { implicitHeight: 52 Text { anchors.centerIn: parent text: "Настройки запуска" 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 LabelledField { id: stGameDir x: 20 width: parent.width - 40 label: "Папка .minecraft" placeholder: "по умолчанию" } LabelledField { id: stJavaPath x: 20 width: parent.width - 40 label: "Путь к Java" placeholder: "автоопределение" } Row { x: 20 spacing: 10 LabelledField { id: stMinMemory width: 100 label: "-Xms, МБ" validator: IntValidator { bottom: 0; top: 1048576 } } LabelledField { id: stMaxMemory width: 100 label: "-Xmx, МБ" validator: IntValidator { bottom: 0; top: 1048576 } } LabelledField { id: stWidth width: 100 label: "Ширина окна" validator: IntValidator { bottom: 0; top: 16384 } } LabelledField { id: stHeight width: 100 label: "Высота окна" validator: IntValidator { bottom: 0; top: 16384 } } } LabelledField { id: stJvmArgs x: 20 width: parent.width - 40 label: "Дополнительные аргументы JVM" placeholder: "-XX:+UseG1GC …" } CheckBox { id: stFullscreen x: 20 text: "Запускать в полноэкранном режиме" // Индикатор Basic-стиля — крупный светлый квадрат, на тёмном // фоне диалога он не читается. Рисуем свой в палитре окна. indicator: Rectangle { implicitWidth: 18 implicitHeight: 18 x: stFullscreen.leftPadding y: (stFullscreen.height - height) / 2 radius: 4 color: "#2a2a2a" border.color: stFullscreen.checked ? "#91B315" : "#444444" border.width: 1 Rectangle { anchors.centerIn: parent width: 10; height: 10 radius: 2 color: "#91B315" visible: stFullscreen.checked } } contentItem: Text { text: stFullscreen.text color: "#ffffff" leftPadding: (stFullscreen.indicator ? stFullscreen.indicator.width : 0) + 6 verticalAlignment: Text.AlignVCenter } } Text { id: stResolved x: 20 width: parent.width - 40 color: "#888888" font.pixelSize: 11 wrapMode: Text.Wrap } Text { id: stJavaList x: 20 width: parent.width - 40 color: "#888888" font.pixelSize: 11 wrapMode: Text.Wrap } } footer: Item { implicitHeight: 60 Rectangle { anchors.top: parent.top width: parent.width height: 1 color: "#333333" } Row { anchors.centerIn: parent spacing: 12 Button { text: "Отмена" 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: settingsDialog.reject() } Button { text: "Сохранить" width: 110; 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: settingsDialog.accept() } } } onAccepted: backend.updateSettings({ "gameDir": stGameDir.text.trim(), "javaPath": stJavaPath.text.trim(), "minMemoryMb": parseInt(stMinMemory.text) || 0, "maxMemoryMb": parseInt(stMaxMemory.text) || 0, "jvmArgs": stJvmArgs.text.trim(), "windowWidth": parseInt(stWidth.text) || 0, "windowHeight": parseInt(stHeight.text) || 0, "fullscreen": stFullscreen.checked }) } // ── Переиспользуемые элементы в общем стиле окна ─────────────────────── component DarkCombo: ComboBox { id: darkCombo // Та же стрелка, что у комбобоксов профиля и версии на главном окне, // вместо двойного шеврона Basic-стиля. indicator: Image { width: 10; height: 10 x: darkCombo.width - width - 12 y: (darkCombo.height - height) / 2 source: darkCombo.down ? "images/Profile_Box/Asset_23.svg" : "images/Profile_Box/Asset_24.svg" rotation: 180 sourceSize.width: 10; sourceSize.height: 10 fillMode: Image.PreserveAspectFit } contentItem: Text { leftPadding: 10 rightPadding: darkCombo.indicator.width + 22 text: darkCombo.displayText color: "#ffffff" font: darkCombo.font verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } background: Rectangle { color: "#2a2a2a" radius: 6 border.color: darkCombo.activeFocus ? "#91B315" : "#444444" border.width: 1 } delegate: ItemDelegate { required property var modelData required property int index width: darkCombo.width highlighted: darkCombo.highlightedIndex === index contentItem: Text { text: modelData color: "#ffffff" elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } background: Rectangle { color: highlighted ? "#3a3a3a" : "#2a2a2a" } } popup: Popup { y: darkCombo.height width: darkCombo.width implicitHeight: Math.min(contentItem.implicitHeight, 220) padding: 1 contentItem: ListView { clip: true implicitHeight: contentHeight model: darkCombo.delegateModel currentIndex: darkCombo.highlightedIndex ScrollIndicator.vertical: ScrollIndicator {} } background: Rectangle { color: "#2a2a2a" radius: 6 border.color: "#444444" border.width: 1 } } } // Выпадающий список версий: сверху установленные, ниже через разделитель // весь каталог Mojang, сверху поле поиска. // // Это Item с Popup, а не ComboBox: editable-ComboBox привязывает поле ввода // к currentText и запускает своё автодополнение, а при активном фильтре // currentIndex перестаёт указывать в исходную модель. component VersionCatalogCombo: 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() } } } } } } } } // Подпись + поле ввода одной колонкой — используется в настройках. component LabelledField: Column { id: labelled property alias text: field.text property alias validator: field.validator property string label: "" property string placeholder: "" spacing: 3 Text { text: labelled.label color: "#aaaaaa" font.pixelSize: 11 } TextField { id: field width: labelled.width height: 32 placeholderText: labelled.placeholder color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { color: "#2a2a2a" radius: 6 border.color: field.activeFocus ? "#91B315" : "#444444" border.width: 1 } } } }