diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index acb23f20..e76d5f47 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -465,6 +465,8 @@ Singleton { property bool launcherUseOverlayLayer: false property string launcherStyle: "full" property bool spotlightBarShowModeChips: false + property bool keybindsFloatingWindow: false + onKeybindsFloatingWindowChanged: saveSettings() property string _legacyWeatherLocation: "New York, NY" property string _legacyWeatherCoordinates: "40.7128,-74.0060" diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 166e78de..65deac09 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -230,6 +230,7 @@ var SPEC = { launcherUseOverlayLayer: { def: false }, launcherStyle: { def: "full" }, spotlightBarShowModeChips: { def: false }, + keybindsFloatingWindow: { def: false }, useAutoLocation: { def: false }, weatherEnabled: { def: true }, diff --git a/quickshell/Modals/KeybindsContent.qml b/quickshell/Modals/KeybindsContent.qml new file mode 100644 index 00000000..eb5728eb --- /dev/null +++ b/quickshell/Modals/KeybindsContent.qml @@ -0,0 +1,336 @@ +import QtQml +import QtQuick +import QtQuick.Layouts +import qs.Common +import qs.Services +import qs.Widgets + +FocusScope { + id: content + + property real scrollStep: 60 + property var activeFlickable: mainFlickable + property bool showFloatingToggle: true + property bool floating: false + property alias searchField: searchField + + signal closeRequested + signal floatingToggleRequested + + function scrollDown() { + if (!activeFlickable) + return; + let newY = activeFlickable.contentY + scrollStep; + newY = Math.min(newY, activeFlickable.contentHeight - activeFlickable.height); + activeFlickable.contentY = newY; + } + + function scrollUp() { + if (!activeFlickable) + return; + let newY = activeFlickable.contentY - scrollStep; + newY = Math.max(0, newY); + activeFlickable.contentY = newY; + } + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_J: + if (event.modifiers & Qt.ControlModifier) { + scrollDown(); + event.accepted = true; + } + return; + case Qt.Key_K: + if (event.modifiers & Qt.ControlModifier) { + scrollUp(); + event.accepted = true; + } + return; + case Qt.Key_Down: + scrollDown(); + event.accepted = true; + return; + case Qt.Key_Up: + scrollUp(); + event.accepted = true; + return; + } + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + RowLayout { + width: parent.width + spacing: Theme.spacingM + + StyledText { + Layout.alignment: Qt.AlignLeft + text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + color: Theme.primary + } + + Item { + Layout.fillWidth: true + } + + DankActionButton { + visible: content.showFloatingToggle + iconName: content.floating ? "close_fullscreen" : "open_in_new" + tooltipText: content.floating ? I18n.tr("Dock window") : I18n.tr("Open as window") + onClicked: content.floatingToggleRequested() + } + + DankTextField { + id: searchField + Layout.alignment: Qt.AlignRight + leftIconName: "search" + keyForwardTargets: [content] + onTextEdited: searchDebounce.restart() + Keys.onEscapePressed: event => { + content.closeRequested(); + event.accepted = true; + } + } + } + + Timer { + id: searchDebounce + interval: 50 + repeat: false + onTriggered: { + mainFlickable.categories = mainFlickable.generateCategories(searchField.text); + } + } + + DankFlickable { + id: mainFlickable + width: parent.width + height: parent.height - parent.spacing - 40 + contentWidth: rowLayout.implicitWidth + contentHeight: rowLayout.implicitHeight + clip: true + + property var rawBinds: KeybindsService.cheatsheet.binds || {} + + function generateCategories(query) { + const lowerQuery = query ? query.toLowerCase().trim() : ""; + const lowerQueryWords = query.split(/\s+/); + const processed = {}; + + for (const cat in rawBinds) { + const binds = rawBinds[cat]; + const catLower = cat.toLowerCase(); + const subcats = {}; + let hasSubcats = false; + for (let i = 0; i < binds.length; i++) { + const bind = binds[i]; + const keyLower = (bind.key || "").toLowerCase(); + const descLower = (bind.desc || "").toLowerCase(); + const actionLower = (bind.action || "").toLowerCase(); + + if (bind.hideOnOverlay) + continue; + let shouldContinue = false; + for (let j = 0; j < lowerQueryWords.length; j++) { + const word = lowerQueryWords[j]; + if (!(word.length === 0 || keyLower.includes(word) || descLower.includes(word) || catLower.includes(word) || actionLower.includes(word))) { + shouldContinue = true; + break; + } + } + if (shouldContinue) + continue; + + if (bind.subcat) { + hasSubcats = true; + if (!subcats[bind.subcat]) + subcats[bind.subcat] = []; + subcats[bind.subcat].push(bind); + } else { + if (!subcats["_root"]) + subcats["_root"] = []; + subcats["_root"].push(bind); + } + } + + if (Object.keys(subcats).length === 0) + continue; + + processed[cat] = { + hasSubcats: hasSubcats, + subcats: subcats, + subcatKeys: Object.keys(subcats) + }; + } + + return processed; + } + + property var categories: generateCategories("") + + function estimateCategoryHeight(catName) { + const catData = categories[catName]; + if (!catData) + return 0; + let bindCount = 0; + for (const key of catData.subcatKeys) { + bindCount += catData.subcats[key]?.length || 0; + if (key !== "_root") + bindCount += 1; + } + return 40 + bindCount * 28; + } + + property var categoryKeys: Object.keys(categories) + + function distributeCategories(cols) { + const columns = []; + const heights = []; + for (let i = 0; i < cols; i++) { + columns.push([]); + heights.push(0); + } + const sorted = [...categoryKeys].sort((a, b) => estimateCategoryHeight(b) - estimateCategoryHeight(a)); + for (const cat of sorted) { + let minIdx = 0; + for (let i = 1; i < cols; i++) { + if (heights[i] < heights[minIdx]) + minIdx = i; + } + columns[minIdx].push(cat); + heights[minIdx] += estimateCategoryHeight(cat); + } + return columns; + } + + Row { + id: rowLayout + width: mainFlickable.width + spacing: Theme.spacingM + + property int numColumns: Math.max(1, Math.min(3, Math.floor(width / 350))) + property var columnCategories: mainFlickable.distributeCategories(numColumns) + + Repeater { + model: rowLayout.numColumns + + Column { + id: masonryColumn + width: (rowLayout.width - rowLayout.spacing * (rowLayout.numColumns - 1)) / rowLayout.numColumns + spacing: Theme.spacingXL + + Repeater { + model: rowLayout.columnCategories[index] || [] + + Column { + id: categoryColumn + width: parent.width + spacing: Theme.spacingXS + + property string catName: modelData + property var catData: mainFlickable.categories[catName] + + StyledText { + text: categoryColumn.catName + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Bold + color: Theme.primary + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.primary + opacity: 0.3 + } + + Item { + width: 1 + height: Theme.spacingXS + } + + Column { + width: parent.width + spacing: Theme.spacingM + + Repeater { + model: categoryColumn.catData?.subcatKeys || [] + + Column { + width: parent.width + spacing: Theme.spacingXS + + property string subcatName: modelData + property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || [] + + StyledText { + visible: parent.subcatName !== "_root" + text: parent.subcatName + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + color: Theme.primary + opacity: 0.7 + } + + Column { + width: parent.width + spacing: Theme.spacingXS + + Repeater { + model: parent.parent.subcatBinds + + Item { + width: parent.width + height: 24 + + StyledRect { + id: keyBadge + width: Math.min(keyText.implicitWidth + 12, 160) + height: 22 + radius: 4 + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: keyText + anchors.centerIn: parent + color: Theme.secondary + text: (modelData.key || "").replace(/\+/g, " + ") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + isMonospace: true + elide: Text.ElideRight + width: Math.min(implicitWidth, 148) + } + } + + StyledText { + anchors.left: parent.left + anchors.leftMargin: 170 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: modelData.desc || modelData.action || "" + font.pixelSize: Theme.fontSizeSmall + opacity: 0.9 + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/quickshell/Modals/KeybindsModal.qml b/quickshell/Modals/KeybindsModal.qml index 78d2f330..b0e0103e 100644 --- a/quickshell/Modals/KeybindsModal.qml +++ b/quickshell/Modals/KeybindsModal.qml @@ -1,334 +1,78 @@ -import QtQml import QtQuick -import QtQuick.Layouts import qs.Common -import qs.Modals.Common +import qs.Modals import qs.Services -import qs.Widgets -DankModal { +Item { id: root - layerNamespace: "dms:keybinds" - useOverlayLayer: true - property real scrollStep: 60 - property var activeFlickable: null - property real _maxW: Math.min(root.screenWidth * 0.92, 1200) - property real _maxH: Math.min(root.screenHeight * 0.92, 900) - modalWidth: _maxW - modalHeight: _maxH - onBackgroundClicked: close() - onOpened: { - Qt.callLater(() => { - modalFocusScope.forceActiveFocus(); - if (contentLoader.item?.searchField) - contentLoader.item.searchField.forceActiveFocus(); - }); - if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable) - KeybindsService.loadCheatsheet(); - } + readonly property bool floating: SettingsData.keybindsFloatingWindow + readonly property bool shouldBeVisible: floating ? (windowLoader.item ? windowLoader.item.visible : false) : (overlayLoader.item ? overlayLoader.item.shouldBeVisible : false) - function scrollDown() { - if (!root.activeFlickable) + function open() { + if (floating) { + windowLoader.active = true; + windowLoader.item.show(); return; - let newY = root.activeFlickable.contentY + scrollStep; - newY = Math.min(newY, root.activeFlickable.contentHeight - root.activeFlickable.height); - root.activeFlickable.contentY = newY; + } + overlayLoader.active = true; + overlayLoader.item.open(); } - function scrollUp() { - if (!root.activeFlickable) + function close() { + if (windowLoader.item) + windowLoader.item.hide(); + if (overlayLoader.item) + overlayLoader.item.close(); + } + + function toggle() { + if (shouldBeVisible) + close(); + else + open(); + } + + function _switchFloating(toFloating) { + if (toFloating) { + if (overlayLoader.item) + overlayLoader.item.close(); + SettingsData.keybindsFloatingWindow = true; + windowLoader.active = true; + windowLoader.item.show(); return; - let newY = root.activeFlickable.contentY - root.scrollStep; - newY = Math.max(0, newY); - root.activeFlickable.contentY = newY; + } + if (windowLoader.item) + windowLoader.item.hide(); + SettingsData.keybindsFloatingWindow = false; + overlayLoader.active = true; + overlayLoader.item.open(); } - modalFocusScope.Keys.onPressed: event => { - if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) { - scrollDown(); - event.accepted = true; - } else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) { - scrollUp(); - event.accepted = true; - } else if (event.key === Qt.Key_Down) { - scrollDown(); - event.accepted = true; - } else if (event.key === Qt.Key_Up) { - scrollUp(); - event.accepted = true; + Loader { + id: overlayLoader + active: false + asynchronous: false + + sourceComponent: KeybindsModalOverlay { + onFloatingToggleRequested: root._switchFloating(true) + onDialogClosed: Qt.callLater(() => { + if (!shouldBeVisible) + overlayLoader.active = false; + }) } } - content: Component { - Item { - anchors.fill: parent - property alias searchField: searchField + Loader { + id: windowLoader + active: false + asynchronous: false - Column { - anchors.fill: parent - anchors.margins: Theme.spacingL - spacing: Theme.spacingL - - RowLayout { - width: parent.width - - StyledText { - Layout.alignment: Qt.AlignLeft - text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds") - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.Bold - color: Theme.primary - } - - DankTextField { - id: searchField - Layout.alignment: Qt.AlignRight - leftIconName: "search" - keyForwardTargets: [root.modalFocusScope] - onTextEdited: searchDebounce.restart() - Keys.onEscapePressed: event => { - root.close(); - event.accepted = true; - } - } - } - - Timer { - id: searchDebounce - interval: 50 - repeat: false - onTriggered: { - mainFlickable.categories = mainFlickable.generateCategories(searchField.text); - } - } - - DankFlickable { - id: mainFlickable - width: parent.width - height: parent.height - parent.spacing - 40 - contentWidth: rowLayout.implicitWidth - contentHeight: rowLayout.implicitHeight - clip: true - - Component.onCompleted: root.activeFlickable = mainFlickable - - property var rawBinds: KeybindsService.cheatsheet.binds || {} - - function generateCategories(query) { - const lowerQuery = query ? query.toLowerCase().trim() : ""; - const lowerQueryWords = query.split(/\s+/); - const processed = {}; - - for (const cat in rawBinds) { - const binds = rawBinds[cat]; - const catLower = cat.toLowerCase(); - const subcats = {}; - let hasSubcats = false; - for (let i = 0; i < binds.length; i++) { - const bind = binds[i]; - const keyLower = (bind.key || "").toLowerCase(); - const descLower = (bind.desc || "").toLowerCase(); - const actionLower = (bind.action || "").toLowerCase(); - - if (bind.hideOnOverlay) - continue; - let shouldContinue = false; - for (let j = 0; j < lowerQueryWords.length; j++) { - const word = lowerQueryWords[j]; - if (!(word.length === 0 || keyLower.includes(word) || descLower.includes(word) || catLower.includes(word) || actionLower.includes(word))) { - shouldContinue = true; - break; - } - } - if (shouldContinue) - continue; - - if (bind.subcat) { - hasSubcats = true; - if (!subcats[bind.subcat]) - subcats[bind.subcat] = []; - subcats[bind.subcat].push(bind); - } else { - if (!subcats["_root"]) - subcats["_root"] = []; - subcats["_root"].push(bind); - } - } - - if (Object.keys(subcats).length === 0) - continue; - - processed[cat] = { - hasSubcats: hasSubcats, - subcats: subcats, - subcatKeys: Object.keys(subcats) - }; - } - - return processed; - } - - property var categories: generateCategories("") - - function estimateCategoryHeight(catName) { - const catData = categories[catName]; - if (!catData) - return 0; - let bindCount = 0; - for (const key of catData.subcatKeys) { - bindCount += catData.subcats[key]?.length || 0; - if (key !== "_root") - bindCount += 1; - } - return 40 + bindCount * 28; - } - - property var categoryKeys: Object.keys(categories) - - function distributeCategories(cols) { - const columns = []; - const heights = []; - for (let i = 0; i < cols; i++) { - columns.push([]); - heights.push(0); - } - const sorted = [...categoryKeys].sort((a, b) => estimateCategoryHeight(b) - estimateCategoryHeight(a)); - for (const cat of sorted) { - let minIdx = 0; - for (let i = 1; i < cols; i++) { - if (heights[i] < heights[minIdx]) - minIdx = i; - } - columns[minIdx].push(cat); - heights[minIdx] += estimateCategoryHeight(cat); - } - return columns; - } - - Row { - id: rowLayout - width: mainFlickable.width - spacing: Theme.spacingM - - property int numColumns: Math.max(1, Math.min(3, Math.floor(width / 350))) - property var columnCategories: mainFlickable.distributeCategories(numColumns) - - Repeater { - model: rowLayout.numColumns - - Column { - id: masonryColumn - width: (rowLayout.width - rowLayout.spacing * (rowLayout.numColumns - 1)) / rowLayout.numColumns - spacing: Theme.spacingXL - - Repeater { - model: rowLayout.columnCategories[index] || [] - - Column { - id: categoryColumn - width: parent.width - spacing: Theme.spacingXS - - property string catName: modelData - property var catData: mainFlickable.categories[catName] - - StyledText { - text: categoryColumn.catName - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Bold - color: Theme.primary - } - - Rectangle { - width: parent.width - height: 1 - color: Theme.primary - opacity: 0.3 - } - - Item { - width: 1 - height: Theme.spacingXS - } - - Column { - width: parent.width - spacing: Theme.spacingM - - Repeater { - model: categoryColumn.catData?.subcatKeys || [] - - Column { - width: parent.width - spacing: Theme.spacingXS - - property string subcatName: modelData - property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || [] - - StyledText { - visible: parent.subcatName !== "_root" - text: parent.subcatName - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.DemiBold - color: Theme.primary - opacity: 0.7 - } - - Column { - width: parent.width - spacing: Theme.spacingXS - - Repeater { - model: parent.parent.subcatBinds - - Item { - width: parent.width - height: 24 - - StyledRect { - id: keyBadge - width: Math.min(keyText.implicitWidth + 12, 160) - height: 22 - radius: 4 - anchors.verticalCenter: parent.verticalCenter - - StyledText { - id: keyText - anchors.centerIn: parent - color: Theme.secondary - text: (modelData.key || "").replace(/\+/g, " + ") - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - isMonospace: true - elide: Text.ElideRight - width: Math.min(implicitWidth, 148) - } - } - - StyledText { - anchors.left: parent.left - anchors.leftMargin: 170 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: modelData.desc || modelData.action || "" - font.pixelSize: Theme.fontSizeSmall - opacity: 0.9 - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - } - } - } - } - } - } - } - } - } - } - } + sourceComponent: KeybindsModalWindow { + onFloatingToggleRequested: root._switchFloating(false) + onVisibleChanged: { + if (!visible) + Qt.callLater(() => windowLoader.active = false); } } } diff --git a/quickshell/Modals/KeybindsModalOverlay.qml b/quickshell/Modals/KeybindsModalOverlay.qml new file mode 100644 index 00000000..c298da4c --- /dev/null +++ b/quickshell/Modals/KeybindsModalOverlay.qml @@ -0,0 +1,38 @@ +import QtQml +import QtQuick +import qs.Common +import qs.Modals +import qs.Modals.Common +import qs.Services + +DankModal { + id: overlay + + signal floatingToggleRequested + + layerNamespace: "dms:keybinds" + useOverlayLayer: true + property real _maxW: Math.min(overlay.screenWidth * 0.92, 1200) + property real _maxH: Math.min(overlay.screenHeight * 0.92, 900) + modalWidth: _maxW + modalHeight: _maxH + onBackgroundClicked: close() + onOpened: { + Qt.callLater(() => { + modalFocusScope.forceActiveFocus(); + if (contentLoader.item?.searchField) + contentLoader.item.searchField.forceActiveFocus(); + }); + if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable) + KeybindsService.loadCheatsheet(); + } + + content: Component { + KeybindsContent { + showFloatingToggle: true + floating: false + onCloseRequested: overlay.close() + onFloatingToggleRequested: overlay.floatingToggleRequested() + } + } +} diff --git a/quickshell/Modals/KeybindsModalWindow.qml b/quickshell/Modals/KeybindsModalWindow.qml new file mode 100644 index 00000000..b980e74a --- /dev/null +++ b/quickshell/Modals/KeybindsModalWindow.qml @@ -0,0 +1,140 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Modals +import qs.Services +import qs.Widgets + +FloatingWindow { + id: win + + property bool disablePopupTransparency: true + property alias shouldBeVisible: win.visible + + signal floatingToggleRequested + + function show() { + visible = true; + } + + function hide() { + visible = false; + } + + function toggle() { + visible = !visible; + } + + objectName: "keybindsModalWindow" + title: I18n.tr("Keybinds") + minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height)) + implicitWidth: 1000 + implicitHeight: screen ? Math.min(820, screen.height - 100) : 820 + color: Theme.surfaceContainer + visible: false + + onVisibleChanged: { + if (!visible) + return; + if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable) + KeybindsService.loadCheatsheet(); + Qt.callLater(() => { + keybindsContent.forceActiveFocus(); + keybindsContent.searchField.forceActiveFocus(); + }); + } + + onClosed: win.visible = false + + Column { + anchors.fill: parent + spacing: 0 + + Item { + width: parent.width + height: 48 + z: 10 + + MouseArea { + anchors.fill: parent + onPressed: windowControls.tryStartMove() + onDoubleClicked: windowControls.tryToggleMaximize() + } + + Rectangle { + anchors.fill: parent + color: Theme.surfaceContainer + opacity: 0.5 + } + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingL + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "keyboard" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds") + font.pixelSize: Theme.fontSizeXLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankActionButton { + circular: false + iconName: "close_fullscreen" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + tooltipText: I18n.tr("Dock window") + onClicked: win.floatingToggleRequested() + } + + DankActionButton { + visible: windowControls.canMaximize + circular: false + iconName: win.maximized ? "fullscreen_exit" : "fullscreen" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: windowControls.tryToggleMaximize() + } + + DankActionButton { + circular: false + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: win.hide() + } + } + } + + KeybindsContent { + id: keybindsContent + width: parent.width + height: parent.height - 48 + showFloatingToggle: false + floating: true + onCloseRequested: win.hide() + } + } + + FloatingWindowControls { + id: windowControls + targetWindow: win + } +}