diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml index cf8730a..01646b2 100644 --- a/src/qml/SettingsPage.qml +++ b/src/qml/SettingsPage.qml @@ -17,6 +17,46 @@ ColumnLayout { property string runnerInstallSha256: "" property string runnerInstallName: "" + property string compatibilityGameId: "" + property string compatibilityLoadedRunnerId: "" + property string compatibilityLoadedRunner: "" + property string compatibilityLastError: "" + + property int compatibilitySpecRevision: 0 + readonly property bool compatibilityHasGame: (compatibilityGameId || "").trim() !== "" + + property int compatibilityRunnerIndex: 0 + property string compatibilityRunnerPath: "" + property string compatibilityPrefixPath: "" + + ListModel { + id: compatibilityGameChoicesModel + } + + ListModel { + id: compatibilityRunnerChoicesModel + } + + ListModel { + id: compatibilityEnvModel + } + + ListModel { + id: compatibilityExtraArgsModel + } + + ListModel { + id: compatibilityDllOverridesModel + } + + property string lastControllerInput: "" + property string lastControllerInputTime: "" + + property string profileMenuControllerId: "" + property string newProfileName: "" + property string pendingDeleteProfileId: "" + property string pendingDeleteProfileName: "" + function focusFirstControl() { showPlatformBadgesDelegate.forceActiveFocus() } @@ -29,8 +69,9 @@ ColumnLayout { || removeMissingConfirmDialog.opened || clearConfirmDialog.opened || resetConfirmDialog.opened + || deleteProfileConfirmDialog.opened - readonly property bool anyMenuOpen: uiModeMenu.visible + readonly property bool anyMenuOpen: uiModeMenu.visible || profileMenu.visible function currentConfirmDialog() { if (disableImportConfirmDialog.opened) return disableImportConfirmDialog @@ -38,6 +79,7 @@ ColumnLayout { if (removeMissingConfirmDialog.opened) return removeMissingConfirmDialog if (clearConfirmDialog.opened) return clearConfirmDialog if (resetConfirmDialog.opened) return resetConfirmDialog + if (deleteProfileConfirmDialog.opened) return deleteProfileConfirmDialog return null } @@ -52,9 +94,14 @@ ColumnLayout { } function closeCurrentMenu() { - if (uiModeMenu.visible) { - uiModeMenu.close() - } + let m = currentMenu() + if (m && m.close) m.close() + } + + function currentMenu() { + if (profileMenu.visible) return profileMenu + if (uiModeMenu.visible) return uiModeMenu + return null } function isDescendant(item, ancestor) { @@ -75,20 +122,658 @@ ColumnLayout { function focusNextInMenu(forward) { let w = applicationWindow() if (!w || !w.activeFocusItem) return + let menu = currentMenu() + if (!menu) return let next = w.activeFocusItem for (let i = 0; i < 50; i++) { next = next.nextItemInFocusChain(forward) if (!next) return - if (settingsPage.isDescendant(next, uiModeMenu)) { + if (settingsPage.isDescendant(next, menu)) { next.forceActiveFocus() return } } } + function rebuildCompatibilityGameChoices() { + compatibilityGameChoicesModel.clear() + if (!App || !App.gameModel) return + for (let i = 0; i < App.gameModel.count; i++) { + let g = App.gameModel.gameAt(i) + if (!g) continue + compatibilityGameChoicesModel.append({ gameId: String(g.id || ""), label: String(g.name || g.id || "") }) + } + + if (compatibilityGameId === "") { + if (compatibilityGameChoicesModel.count > 0) { + compatibilityGameId = String(compatibilityGameChoicesModel.get(0).gameId || "") + } + } + } + + function rebuildCompatibilityRunnerChoices() { + compatibilityRunnerChoicesModel.clear() + + compatibilityRunnerChoicesModel.append({ kind: "native", label: i18n("Native"), runner: "", runnerId: "", runnerType: "" }) + compatibilityRunnerChoicesModel.append({ kind: "wine", label: i18n("Wine (system)"), runner: "wine", runnerId: "", runnerType: "wine" }) + compatibilityRunnerChoicesModel.append({ kind: "proton", label: i18n("Proton (auto)"), runner: "proton", runnerId: "", runnerType: "proton" }) + + let runners = (App.runnerManager && App.runnerManager.runners) ? App.runnerManager.runners : [] + for (let i = 0; i < runners.length; i++) { + let r = runners[i] + if (!r) continue + let name = r.name ? String(r.name) : "" + let type = r.type ? String(r.type) : "" + let source = r.source ? String(r.source) : "" + let label = name + if (type !== "" || source !== "") { + label = name + " (" + type + (source !== "" ? (" · " + source) : "") + ")" + } + compatibilityRunnerChoicesModel.append({ kind: "inventory", label: label, runner: "", runnerId: String(r.id || ""), runnerType: type }) + } + + compatibilityRunnerChoicesModel.append({ kind: "custom", label: i18n("Custom runner path"), runner: "custom", runnerId: "", runnerType: "custom" }) + } + + function compatibilityIndexForGameId(gameId) { + let id = (gameId || "").trim() + if (id === "") return 0 + for (let i = 0; i < compatibilityGameChoicesModel.count; i++) { + let row = compatibilityGameChoicesModel.get(i) + if (row && String(row.gameId || "") === id) return i + } + return 0 + } + + function compatibilityCurrentRunnerChoice() { + if (!compatibilityRunnerChoicesModel || compatibilityRunnerChoicesModel.count <= 0) return null + let idx = settingsPage.compatibilityRunnerIndex + if (idx < 0 || idx >= compatibilityRunnerChoicesModel.count) idx = 0 + return compatibilityRunnerChoicesModel.get(idx) + } + + function compatibilityCurrentRunnerChoiceKind() { + let c = settingsPage.compatibilityCurrentRunnerChoice() + if (!c || !c.kind) return "native" + return String(c.kind) + } + + function compatibilityCurrentRunnerChoiceRunnerType() { + let c = settingsPage.compatibilityCurrentRunnerChoice() + if (!c) return "" + if (c.runnerType) return String(c.runnerType) + if (c.runner) return String(c.runner) + return "" + } + + function compatibilityIndexForRunnerId(runnerId) { + let id = (runnerId || "").trim() + if (id === "") return 0 + for (let i = 0; i < compatibilityRunnerChoicesModel.count; i++) { + let row = compatibilityRunnerChoicesModel.get(i) + if (row.kind === "inventory" && String(row.runnerId || "") === id) return i + } + return 0 + } + + function compatibilityIndexForRunner(runner) { + let r = (runner || "").trim().toLowerCase() + if (r === "wine") return 1 + if (r === "proton") return 2 + if (r === "custom") return Math.max(0, compatibilityRunnerChoicesModel.count - 1) + return 0 + } + + function compatibilitySyncRunnerIndexFromLoaded() { + if ((settingsPage.compatibilityLoadedRunnerId || "") !== "") { + settingsPage.compatibilityRunnerIndex = settingsPage.compatibilityIndexForRunnerId(settingsPage.compatibilityLoadedRunnerId) + return + } + if ((settingsPage.compatibilityLoadedRunner || "") !== "") { + settingsPage.compatibilityRunnerIndex = settingsPage.compatibilityIndexForRunner(settingsPage.compatibilityLoadedRunner) + return + } + settingsPage.compatibilityRunnerIndex = 0 + } + + function compatibilitySetEnvFromMap(envMap) { + compatibilityEnvModel.clear() + if (!envMap) return + let keys = Object.keys(envMap) + keys.sort() + for (let i = 0; i < keys.length; i++) { + let k = keys[i] + compatibilityEnvModel.append({ key: String(k), value: String(envMap[k] || "") }) + } + } + + function compatibilityEnvModelToMap() { + let result = ({}) + for (let i = 0; i < compatibilityEnvModel.count; i++) { + let row = compatibilityEnvModel.get(i) + let k = (row.key || "").trim() + if (k === "") continue + if (k.indexOf("=") !== -1) continue + result[k] = String(row.value || "") + } + return result + } + + function compatibilitySetExtraArgsFromList(list) { + compatibilityExtraArgsModel.clear() + if (!list || !Array.isArray(list)) return + for (let i = 0; i < list.length; i++) { + let a = String(list[i] || "").trim() + if (a === "") continue + compatibilityExtraArgsModel.append({ arg: a }) + } + } + + function compatibilityExtraArgsModelToList() { + let result = [] + for (let i = 0; i < compatibilityExtraArgsModel.count; i++) { + let row = compatibilityExtraArgsModel.get(i) + let a = (row.arg || "").trim() + if (a === "") continue + result.push(a) + } + return result + } + + function compatibilitySetDllOverridesFromMap(dllMap) { + compatibilityDllOverridesModel.clear() + if (!dllMap) return + let keys = Object.keys(dllMap) + keys.sort() + for (let i = 0; i < keys.length; i++) { + let k = String(keys[i] || "").trim() + if (k === "") continue + compatibilityDllOverridesModel.append({ key: k, value: String(dllMap[k] || "") }) + } + } + + function compatibilityDllOverridesModelToMap() { + let result = ({}) + for (let i = 0; i < compatibilityDllOverridesModel.count; i++) { + let row = compatibilityDllOverridesModel.get(i) + let k = (row.key || "").trim() + if (k === "") continue + if (k.indexOf("=") !== -1) continue + if (k.indexOf(";") !== -1) continue + let v = (row.value || "").trim() + if (v === "") continue + result[k] = String(v) + } + return result + } + + function compatibilitySetEnvVar(key, value) { + let k = (key || "").trim() + if (k === "") return + for (let i = 0; i < compatibilityEnvModel.count; i++) { + let row = compatibilityEnvModel.get(i) + if ((row.key || "").trim() === k) { + compatibilityEnvModel.setProperty(i, "value", String(value || "")) + settingsPage.compatibilityBumpSpecRevision() + return + } + } + compatibilityEnvModel.append({ key: k, value: String(value || "") }) + settingsPage.compatibilityBumpSpecRevision() + } + + function compatibilityBumpSpecRevision() { + settingsPage.compatibilitySpecRevision = settingsPage.compatibilitySpecRevision + 1 + } + + function compatibilityResetEditor() { + settingsPage.compatibilityLastError = "" + settingsPage.compatibilityLoadedRunnerId = "" + settingsPage.compatibilityLoadedRunner = "" + settingsPage.compatibilityRunnerPath = "" + settingsPage.compatibilityPrefixPath = "" + settingsPage.compatibilityRunnerIndex = 0 + compatibilityEnvModel.clear() + compatibilityExtraArgsModel.clear() + compatibilityDllOverridesModel.clear() + settingsPage.compatibilityBumpSpecRevision() + } + + function compatibilityApplyProfile(profile) { + let p = profile || ({}) + settingsPage.compatibilityLastError = "" + + settingsPage.compatibilityLoadedRunnerId = String(p.runnerId || "") + settingsPage.compatibilityLoadedRunner = String(p.runner || "") + settingsPage.compatibilityRunnerPath = String(p.runnerPath || "") + settingsPage.compatibilityPrefixPath = String(p.prefixPath || "") + + settingsPage.compatibilitySyncRunnerIndexFromLoaded() + settingsPage.compatibilitySetEnvFromMap(p.envOverrides || ({})) + settingsPage.compatibilitySetExtraArgsFromList(p.extraArgs || ([])) + settingsPage.compatibilitySetDllOverridesFromMap(p.dllOverrides || ({})) + settingsPage.compatibilityBumpSpecRevision() + } + + function compatibilityRequestProfile() { + let id = (settingsPage.compatibilityGameId || "").trim() + if (id === "") return + if (!App || !App.runnerManager) return + App.runnerManager.requestGameProfile(id) + } + + function compatibilityBuildProfileSpec() { + let id = (settingsPage.compatibilityGameId || "").trim() + if (id === "") return ({}) + + let choice = settingsPage.compatibilityCurrentRunnerChoice() + let kind = choice && choice.kind ? String(choice.kind) : "native" + let runnerType = settingsPage.compatibilityCurrentRunnerChoiceRunnerType() + let runnerId = choice && choice.runnerId ? String(choice.runnerId) : "" + let runner = choice && choice.runner ? String(choice.runner) : "" + + let spec = ({ gameId: id }) + if (runnerId !== "") spec.runnerId = runnerId + if (runner !== "") spec.runner = runner + if ((kind === "custom" || kind === "wine" || kind === "proton") && (settingsPage.compatibilityRunnerPath || "").trim() !== "") { + spec.runnerPath = settingsPage.compatibilityRunnerPath.trim() + } + if ((runnerType === "wine" || runnerType === "proton") && (settingsPage.compatibilityPrefixPath || "").trim() !== "") { + spec.prefixPath = settingsPage.compatibilityPrefixPath.trim() + } + + let env = settingsPage.compatibilityEnvModelToMap() + if (Object.keys(env).length > 0) spec.envOverrides = env + + let extraArgs = settingsPage.compatibilityExtraArgsModelToList() + if (extraArgs.length > 0) spec.extraArgs = extraArgs + + let dllOverrides = settingsPage.compatibilityDllOverridesModelToMap() + if (Object.keys(dllOverrides).length > 0) spec.dllOverrides = dllOverrides + + return spec + } + + function compatibilityCanSaveProfile() { + let spec = settingsPage.compatibilityBuildProfileSpec() + if (!spec || Object.keys(spec).length <= 1) return false + return true + } + + function compatibilitySaveProfile() { + let id = (settingsPage.compatibilityGameId || "").trim() + if (id === "") return + if (!App || !App.runnerManager) return + + let spec = settingsPage.compatibilityBuildProfileSpec() + if (!spec || Object.keys(spec).length <= 1) return + App.runnerManager.setGameProfile(spec) + } + + function compatibilityClearProfile() { + let id = (settingsPage.compatibilityGameId || "").trim() + if (id === "") return + if (!App || !App.runnerManager) return + App.runnerManager.clearGameProfile(id) + } + + onCompatibilityGameIdChanged: { + settingsPage.compatibilityResetEditor() + settingsPage.compatibilityRequestProfile() + } + + Component.onCompleted: { + settingsPage.rebuildCompatibilityGameChoices() + settingsPage.rebuildCompatibilityRunnerChoices() + settingsPage.compatibilityRequestProfile() + } + + Connections { + target: App && App.gameModel ? App.gameModel : null + function onCountChanged() { + settingsPage.rebuildCompatibilityGameChoices() + } + } + + Connections { + target: App && App.runnerManager ? App.runnerManager : null + + function onRunnersChanged() { + settingsPage.rebuildCompatibilityRunnerChoices() + settingsPage.compatibilitySyncRunnerIndexFromLoaded() + } + + function onPrefixEnsured(gameId, prefixPath) { + let id = String(gameId || "") + if (id !== settingsPage.compatibilityGameId) return + settingsPage.compatibilityLastError = "" + if ((settingsPage.compatibilityPrefixPath || "").trim() !== "") { + settingsPage.compatibilityPrefixPath = String(prefixPath || "") + } + } + + function onPrefixDeleted(gameId, prefixPath) { + let id = String(gameId || "") + if (id !== settingsPage.compatibilityGameId) return + settingsPage.compatibilityLastError = "" + } + + function onGameProfileFetched(gameId, result) { + let id = String(gameId || "") + if (id !== settingsPage.compatibilityGameId) return + if (!result || result.ok !== true) { + settingsPage.compatibilityLastError = result && result.error ? String(result.error) : i18n("Failed to load profile") + settingsPage.compatibilityApplyProfile({}) + return + } + settingsPage.compatibilityApplyProfile(result.profile || ({})) + } + + function onGameProfileSaved(gameId, result) { + let id = String(gameId || "") + if (id !== settingsPage.compatibilityGameId) return + if (!result || result.ok !== true) { + settingsPage.compatibilityLastError = result && result.error ? String(result.error) : i18n("Failed to save profile") + return + } + settingsPage.compatibilityApplyProfile(result.profile || ({})) + } + + function onGameProfileCleared(gameId, result) { + let id = String(gameId || "") + if (id !== settingsPage.compatibilityGameId) return + if (!result || result.ok !== true) { + settingsPage.compatibilityLastError = result && result.error ? String(result.error) : i18n("Failed to clear profile") + return + } + settingsPage.compatibilityResetEditor() + } + } + + FormCard.FormHeader { + Layout.topMargin: Kirigami.Units.mediumSpacing + Layout.fillWidth: true + title: i18n("Controller") + } + + FormCard.FormCard { + Layout.fillWidth: true + + FormCard.FormTextDelegate { + text: GamepadManager.connected ? i18n("Connected") : i18n("Not connected") + description: GamepadManager.connected ? (GamepadManager.name !== "" ? GamepadManager.name : i18n("Gamepad")) : i18n("Connect a controller to use couch navigation") + textItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextDelegate { + text: i18n("Device") + description: { + if (!GamepadManager.connected) return i18n("-") + let parts = [] + if (GamepadManager.vendorId > 0) parts.push(i18n("Vendor: %1").arg(GamepadManager.vendorId)) + if (GamepadManager.productId > 0) parts.push(i18n("Product: %1").arg(GamepadManager.productId)) + return parts.length > 0 ? parts.join(" · ") : i18n("-") + } + textItem.wrapMode: Text.WordWrap + descriptionItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextDelegate { + text: i18n("Buttons") + description: { + if (!GamepadManager.connected) return i18n("-") + return i18n("Confirm: %1 · Back: %2 · Details: %3 · Search: %4") + .arg(GamepadManager.confirmButtonLabel) + .arg(GamepadManager.backButtonLabel) + .arg(GamepadManager.detailsButtonLabel) + .arg(GamepadManager.searchButtonLabel) + } + textItem.wrapMode: Text.WordWrap + descriptionItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextDelegate { + text: i18n("Active input") + description: { + if (InputManager.activeInput === InputManager.Gamepad) return i18n("Gamepad") + return i18n("Keyboard & Mouse") + } + textItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextDelegate { + text: i18n("Input service") + description: { + if (App.inputService.available) return i18n("Available") + if (App.inputService.lastError !== "") return App.inputService.lastError + return i18n("Unavailable") + } + descriptionItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Refresh controllers") + icon.name: "view-refresh" + onClicked: App.inputService.refreshControllers() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Refresh profiles") + icon.name: "view-refresh" + onClicked: App.inputService.refreshProfiles() + } + } + + FormCard.FormHeader { + Layout.topMargin: Kirigami.Units.mediumSpacing + Layout.fillWidth: true + title: i18n("Controllers") + } + + FormCard.FormCard { + Layout.fillWidth: true + + Repeater { + model: App.inputService.controllers + + ColumnLayout { + Layout.fillWidth: true + + readonly property var controller: modelData + readonly property string controllerId: controller && controller.controllerId ? controller.controllerId : "" + readonly property string controllerName: controller && controller.name ? controller.name : controllerId + readonly property string connectionState: controller && controller.connectionState ? controller.connectionState : "" + readonly property int batteryPercent: controller && controller.batteryPercent !== undefined ? controller.batteryPercent : -1 + readonly property string batteryState: controller && controller.batteryStateName ? controller.batteryStateName : "" + readonly property string activeProfileName: controller && controller.activeProfileName ? controller.activeProfileName : "" + readonly property string activeProfileId: controller && controller.activeProfileId ? controller.activeProfileId : "default" + + FormCard.FormTextDelegate { + text: controllerName + description: { + let parts = [] + if (connectionState !== "") parts.push(connectionState) + if (batteryPercent >= 0) { + parts.push(i18n("Battery: %1%2").arg(batteryPercent).arg("%")) + } else if (batteryState !== "") { + parts.push(i18n("Battery: %1").arg(batteryState)) + } + return parts.length > 0 ? parts.join(" · ") : "" + } + textItem.wrapMode: Text.WordWrap + descriptionItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Profile") + description: activeProfileName !== "" ? activeProfileName : i18n("Default") + icon.name: "preferences-desktop-gaming" + enabled: controllerId !== "" && App.inputService.profiles.length > 0 + onClicked: { + settingsPage.profileMenuControllerId = controllerId + profileMenu.open() + } + } + + FormCard.FormDelegateSeparator {} + } + } + + FormCard.FormTextDelegate { + visible: App.inputService.controllers.length === 0 + text: i18n("No controllers detected") + textItem.font: Kirigami.Theme.smallFont + } + } + + QQC2.Menu { + id: profileMenu + focus: true + + onOpened: Qt.callLater(function() { + if (profileMenu.contentChildren.length > 0 && profileMenu.contentChildren[0].forceActiveFocus) { + profileMenu.contentChildren[0].forceActiveFocus() + } + }) + + Repeater { + model: App.inputService.profiles + delegate: QQC2.MenuItem { + readonly property var profile: modelData + readonly property string profileId: profile && profile.id ? profile.id : "" + text: profile && profile.name ? profile.name : profileId + checkable: true + checked: profileId !== "" && profileId === settingsPage.currentProfileIdForMenu() + onTriggered: { + if (profileId === "" || settingsPage.profileMenuControllerId === "") return + App.inputService.setActiveProfile(settingsPage.profileMenuControllerId, profileId) + } + } + } + } + + function currentProfileIdForMenu() { + for (let i = 0; i < App.inputService.controllers.length; i++) { + let c = App.inputService.controllers[i] + if (c && c.controllerId === settingsPage.profileMenuControllerId) { + return c.activeProfileId ? c.activeProfileId : "default" + } + } + return "default" + } + + FormCard.FormHeader { + Layout.topMargin: Kirigami.Units.mediumSpacing + Layout.fillWidth: true + title: i18n("Controller Profiles") + } + + FormCard.FormCard { + Layout.fillWidth: true + + FormCard.FormTextFieldDelegate { + label: i18n("New profile name") + text: settingsPage.newProfileName + placeholderText: i18n("e.g. Couch") + onTextChanged: settingsPage.newProfileName = text + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Create profile") + icon.name: "list-add" + enabled: settingsPage.newProfileName.trim() !== "" && App.inputService.available + onClicked: { + App.inputService.createProfile(settingsPage.newProfileName.trim()) + settingsPage.newProfileName = "" + } + } + + FormCard.FormDelegateSeparator {} + + Repeater { + model: App.inputService.profiles + + ColumnLayout { + Layout.fillWidth: true + readonly property var profile: modelData + readonly property string profileId: profile && profile.id ? profile.id : "" + readonly property string profileName: profile && profile.name ? profile.name : profileId + + FormCard.FormTextDelegate { + text: profileName + description: profileId + descriptionItem.wrapMode: Text.WrapAnywhere + descriptionItem.font: Kirigami.Theme.smallFont + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Delete") + icon.name: "edit-delete" + enabled: profileId !== "" && profileId !== "default" && App.inputService.available + onClicked: { + settingsPage.pendingDeleteProfileId = profileId + settingsPage.pendingDeleteProfileName = profileName + deleteProfileConfirmDialog.open() + } + } + + FormCard.FormDelegateSeparator {} + } + } + } + + FormCard.FormHeader { + Layout.topMargin: Kirigami.Units.mediumSpacing + Layout.fillWidth: true + title: i18n("Input Test") + } + + FormCard.FormCard { + Layout.fillWidth: true + + FormCard.FormTextDelegate { + text: settingsPage.lastControllerInput !== "" ? settingsPage.lastControllerInput : i18n("No input yet") + description: settingsPage.lastControllerInputTime + textItem.wrapMode: Text.WordWrap + descriptionItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Clear") + icon.name: "edit-clear" + enabled: settingsPage.lastControllerInput !== "" + onClicked: { + settingsPage.lastControllerInput = "" + settingsPage.lastControllerInputTime = "" + } + } + } + function activateFocusedInMenu() { let w = applicationWindow() if (!w || !w.activeFocusItem) return + let menu = currentMenu() + if (!menu) return let item = w.activeFocusItem if (typeof item.triggered === "function") { item.triggered() @@ -184,40 +869,96 @@ ColumnLayout { Connections { target: GamepadManager function onNavigateUp() { - if (!uiModeMenu.visible) return + if (!settingsPage.anyMenuOpen) return + let menu = settingsPage.currentMenu() + if (!menu) return let w = applicationWindow() if (!w || !w.activeFocusItem) return - if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return + if (!settingsPage.isDescendant(w.activeFocusItem, menu)) return settingsPage.focusNextInMenu(false) } function onNavigateDown() { - if (!uiModeMenu.visible) return + if (!settingsPage.anyMenuOpen) return + let menu = settingsPage.currentMenu() + if (!menu) return let w = applicationWindow() if (!w || !w.activeFocusItem) return - if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return + if (!settingsPage.isDescendant(w.activeFocusItem, menu)) return settingsPage.focusNextInMenu(true) } function onNavigateLeft() { - if (!uiModeMenu.visible) return + if (!settingsPage.anyMenuOpen) return + let menu = settingsPage.currentMenu() + if (!menu) return let w = applicationWindow() if (!w || !w.activeFocusItem) return - if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return + if (!settingsPage.isDescendant(w.activeFocusItem, menu)) return settingsPage.focusNextInMenu(false) } function onNavigateRight() { - if (!uiModeMenu.visible) return + if (!settingsPage.anyMenuOpen) return + let menu = settingsPage.currentMenu() + if (!menu) return let w = applicationWindow() if (!w || !w.activeFocusItem) return - if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return + if (!settingsPage.isDescendant(w.activeFocusItem, menu)) return settingsPage.focusNextInMenu(true) } function onSelectPressed() { - if (!uiModeMenu.visible) return + if (!settingsPage.anyMenuOpen) return + let menu = settingsPage.currentMenu() + if (!menu) return let w = applicationWindow() if (!w || !w.activeFocusItem) return - if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return + if (!settingsPage.isDescendant(w.activeFocusItem, menu)) return settingsPage.activateFocusedInMenu() } + + function onBackPressed() { + settingsPage.lastControllerInput = i18n("Back") + settingsPage.lastControllerInputTime = new Date().toLocaleTimeString() + } + function onDetailsPressed() { + settingsPage.lastControllerInput = i18n("Details") + settingsPage.lastControllerInputTime = new Date().toLocaleTimeString() + } + function onMenuPressed() { + settingsPage.lastControllerInput = i18n("Menu") + settingsPage.lastControllerInputTime = new Date().toLocaleTimeString() + } + function onSearchPressed() { + settingsPage.lastControllerInput = i18n("Search") + settingsPage.lastControllerInputTime = new Date().toLocaleTimeString() + } + function onLeftBumperPressed() { + settingsPage.lastControllerInput = i18n("Left bumper") + settingsPage.lastControllerInputTime = new Date().toLocaleTimeString() + } + function onRightBumperPressed() { + settingsPage.lastControllerInput = i18n("Right bumper") + settingsPage.lastControllerInputTime = new Date().toLocaleTimeString() + } + } + + Kirigami.PromptDialog { + id: deleteProfileConfirmDialog + title: i18n("Delete Profile") + subtitle: settingsPage.pendingDeleteProfileName !== "" + ? i18n("Delete '%1'?", settingsPage.pendingDeleteProfileName) + : i18n("Delete this profile?") + standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No + onOpened: settingsPage.focusNoButton(deleteProfileConfirmDialog) + onAccepted: { + if (settingsPage.pendingDeleteProfileId !== "") { + App.inputService.deleteProfile(settingsPage.pendingDeleteProfileId) + } + settingsPage.pendingDeleteProfileId = "" + settingsPage.pendingDeleteProfileName = "" + } + onRejected: { + settingsPage.pendingDeleteProfileId = "" + settingsPage.pendingDeleteProfileName = "" + } } FormCard.FormHeader { @@ -709,6 +1450,402 @@ FormCard.FormHeader { } } + FormCard.FormHeader { + Layout.topMargin: Kirigami.Units.mediumSpacing + Layout.fillWidth: true + title: i18n("Compatibility") + } + + FormCard.FormCard { + Layout.fillWidth: true + + FormCard.FormComboBoxDelegate { + text: i18n("Game") + model: compatibilityGameChoicesModel + textRole: "label" + currentIndex: settingsPage.compatibilityIndexForGameId(settingsPage.compatibilityGameId) + onActivated: { + if (currentIndex < 0 || currentIndex >= compatibilityGameChoicesModel.count) return + settingsPage.compatibilityGameId = String(compatibilityGameChoicesModel.get(currentIndex).gameId || "") + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Reload profile") + icon.name: "view-refresh" + enabled: settingsPage.compatibilityHasGame + onClicked: settingsPage.compatibilityRequestProfile() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextDelegate { + visible: settingsPage.compatibilityLastError !== "" + text: settingsPage.compatibilityLastError + textItem.wrapMode: Text.WordWrap + textItem.color: Kirigami.Theme.negativeTextColor + textItem.font: Kirigami.Theme.smallFont + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormComboBoxDelegate { + text: i18n("Runner") + model: compatibilityRunnerChoicesModel + textRole: "label" + currentIndex: settingsPage.compatibilityRunnerIndex + enabled: settingsPage.compatibilityHasGame + onActivated: { + settingsPage.compatibilityRunnerIndex = currentIndex + settingsPage.compatibilityBumpSpecRevision() + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + label: i18n("Runner Path") + text: settingsPage.compatibilityRunnerPath + placeholderText: settingsPage.compatibilityCurrentRunnerChoiceKind() === "proton" ? i18n("Auto-detect Proton") : (settingsPage.compatibilityCurrentRunnerChoiceKind() === "wine" ? i18n("Use system Wine") : i18n("Required for Custom")) + enabled: settingsPage.compatibilityHasGame && (settingsPage.compatibilityCurrentRunnerChoiceKind() === "custom" || settingsPage.compatibilityCurrentRunnerChoiceKind() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceKind() === "proton") + onTextChanged: { + settingsPage.compatibilityRunnerPath = text + settingsPage.compatibilityBumpSpecRevision() + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + label: i18n("Prefix Path") + text: settingsPage.compatibilityPrefixPath + placeholderText: i18n("Default (AppDataLocation/prefixes/)") + enabled: settingsPage.compatibilityHasGame && (settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton") + onTextChanged: { + settingsPage.compatibilityPrefixPath = text + settingsPage.compatibilityBumpSpecRevision() + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Create Prefix") + icon.name: "folder-new" + enabled: settingsPage.compatibilityHasGame + && (settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton") + onClicked: App.runnerManager.ensurePrefix(settingsPage.compatibilityGameId, settingsPage.compatibilityCurrentRunnerChoiceRunnerType(), settingsPage.compatibilityPrefixPath.trim()) + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Delete Prefix") + icon.name: "edit-delete" + enabled: settingsPage.compatibilityHasGame + && (settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton") + && (settingsPage.compatibilityPrefixPath.trim() === "") + onClicked: App.runnerManager.deletePrefix(settingsPage.compatibilityGameId, settingsPage.compatibilityPrefixPath.trim()) + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Reset Prefix to Default") + icon.name: "edit-clear" + enabled: settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton" + onClicked: settingsPage.compatibilityPrefixPath = "" + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Save Profile") + icon.name: "document-save" + enabled: settingsPage.compatibilityHasGame && settingsPage.compatibilitySpecRevision >= 0 && settingsPage.compatibilityCanSaveProfile() + onClicked: settingsPage.compatibilitySaveProfile() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Clear Profile") + icon.name: "edit-delete" + enabled: settingsPage.compatibilityHasGame + onClicked: settingsPage.compatibilityClearProfile() + } + } + + FormCard.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + + FormCard.FormHeader { + title: i18n("Environment Variables") + } + + FormCard.FormTextDelegate { + text: i18n("These variables are applied only when launching this game from A-La-Karte.") + textItem.wrapMode: Text.WordWrap + textItem.font: Kirigami.Theme.smallFont + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Add Variable") + icon.name: "list-add" + enabled: settingsPage.compatibilityHasGame + onClicked: { + compatibilityEnvModel.append({ key: "", value: "" }) + settingsPage.compatibilityBumpSpecRevision() + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Preset: MangoHud") + description: i18n("Sets MANGOHUD=1") + icon.name: "games-hint" + enabled: settingsPage.compatibilityHasGame + onClicked: settingsPage.compatibilitySetEnvVar("MANGOHUD", "1") + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Preset: GameMode") + description: i18n("Sets GAMEMODERUN=1") + icon.name: "speedometer" + enabled: settingsPage.compatibilityHasGame + onClicked: settingsPage.compatibilitySetEnvVar("GAMEMODERUN", "1") + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Clear Variables") + icon.name: "edit-clear" + enabled: settingsPage.compatibilityHasGame && compatibilityEnvModel.count > 0 + onClicked: { + compatibilityEnvModel.clear() + settingsPage.compatibilityBumpSpecRevision() + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing + + Repeater { + model: compatibilityEnvModel + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + QQC2.TextField { + Layout.fillWidth: true + Layout.preferredWidth: 1 + placeholderText: i18n("KEY") + text: key + enabled: settingsPage.compatibilityHasGame + onTextChanged: { + compatibilityEnvModel.setProperty(index, "key", text) + settingsPage.compatibilityBumpSpecRevision() + } + } + + QQC2.TextField { + Layout.fillWidth: true + Layout.preferredWidth: 2 + placeholderText: i18n("VALUE") + text: value + enabled: settingsPage.compatibilityHasGame + onTextChanged: { + compatibilityEnvModel.setProperty(index, "value", text) + settingsPage.compatibilityBumpSpecRevision() + } + } + + QQC2.ToolButton { + icon.name: "list-remove" + enabled: settingsPage.compatibilityHasGame + onClicked: { + compatibilityEnvModel.remove(index) + settingsPage.compatibilityBumpSpecRevision() + } + } + } + } + } + } + + FormCard.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + + FormCard.FormHeader { + title: i18n("Extra Launch Arguments") + } + + FormCard.FormTextDelegate { + text: i18n("These arguments are appended when launching this game from A-La-Karte.") + textItem.wrapMode: Text.WordWrap + textItem.font: Kirigami.Theme.smallFont + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Add Argument") + icon.name: "list-add" + enabled: settingsPage.compatibilityHasGame + onClicked: { + compatibilityExtraArgsModel.append({ arg: "" }) + settingsPage.compatibilityBumpSpecRevision() + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Clear Arguments") + icon.name: "edit-clear" + enabled: settingsPage.compatibilityHasGame && compatibilityExtraArgsModel.count > 0 + onClicked: { + compatibilityExtraArgsModel.clear() + settingsPage.compatibilityBumpSpecRevision() + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing + + Repeater { + model: compatibilityExtraArgsModel + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + QQC2.TextField { + Layout.fillWidth: true + placeholderText: i18n("ARG") + text: arg + enabled: settingsPage.compatibilityHasGame + onTextChanged: { + compatibilityExtraArgsModel.setProperty(index, "arg", text) + settingsPage.compatibilityBumpSpecRevision() + } + } + + QQC2.ToolButton { + icon.name: "list-remove" + enabled: settingsPage.compatibilityHasGame + onClicked: { + compatibilityExtraArgsModel.remove(index) + settingsPage.compatibilityBumpSpecRevision() + } + } + } + } + } + } + + FormCard.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + + FormCard.FormHeader { + title: i18n("Wine DLL Overrides") + } + + FormCard.FormTextDelegate { + text: i18n("These overrides are applied to Wine/Proton launches via WINEDLLOVERRIDES.") + textItem.wrapMode: Text.WordWrap + textItem.font: Kirigami.Theme.smallFont + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Add Override") + icon.name: "list-add" + enabled: settingsPage.compatibilityHasGame && (settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton") + onClicked: { + compatibilityDllOverridesModel.append({ key: "", value: "" }) + settingsPage.compatibilityBumpSpecRevision() + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Clear Overrides") + icon.name: "edit-clear" + enabled: settingsPage.compatibilityHasGame && compatibilityDllOverridesModel.count > 0 + onClicked: { + compatibilityDllOverridesModel.clear() + settingsPage.compatibilityBumpSpecRevision() + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing + + Repeater { + model: compatibilityDllOverridesModel + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + QQC2.TextField { + Layout.fillWidth: true + Layout.preferredWidth: 1 + placeholderText: i18n("DLL") + text: key + enabled: settingsPage.compatibilityHasGame && (settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton") + onTextChanged: { + compatibilityDllOverridesModel.setProperty(index, "key", text) + settingsPage.compatibilityBumpSpecRevision() + } + } + + QQC2.TextField { + Layout.fillWidth: true + Layout.preferredWidth: 2 + placeholderText: i18n("OVERRIDE") + text: value + enabled: settingsPage.compatibilityHasGame && (settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton") + onTextChanged: { + compatibilityDllOverridesModel.setProperty(index, "value", text) + settingsPage.compatibilityBumpSpecRevision() + } + } + + QQC2.ToolButton { + icon.name: "list-remove" + enabled: settingsPage.compatibilityHasGame && (settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "wine" || settingsPage.compatibilityCurrentRunnerChoiceRunnerType() === "proton") + onClicked: { + compatibilityDllOverridesModel.remove(index) + settingsPage.compatibilityBumpSpecRevision() + } + } + } + } + } + } + FormCard.FormHeader { Layout.topMargin: Kirigami.Units.mediumSpacing Layout.fillWidth: true