Files
DankMaterialShell/quickshell/Modals/KeybindsContent.qml
T

796 lines
32 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQml
import QtQuick
import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.Widgets
import "../Common/KeyUtils.js" as KeyUtils
import "../Common/KeybindActions.js" as Actions
FocusScope {
id: content
focus: true
function getBindLabel(bind) {
if (!bind)
return "";
var label = bind.desc || "";
if (!label && bind.action) {
label = Actions.getActionLabel(bind.action, KeybindsService.currentProvider) || bind.action;
}
if (!label)
return "";
if (label.includes("(by index)") && bind.action) {
var parts = bind.action.trim().split(/\s+/);
if (parts.length > 1) {
var arg = parts[parts.length - 1];
label = label.replace("(by index)", arg).trim();
}
}
if ((bind.action === "next-window" || bind.action === "previous-window") && (bind.key || "").toLowerCase().includes("grave")) {
if (label === "Next Window")
label = "Next Window (Same Application)";
else if (label === "Previous Window")
label = "Previous Window (Same Application)";
}
if (label.startsWith("Media: "))
label = label.slice(7).trim();
return label;
}
Component {
id: keybindItemDelegate
DankListItem {
id: keybindRow
required property var modelData
readonly property bool canExecute: !keybindRow.modelData.isRange && KeybindsService.canExecuteAction(keybindRow.modelData.action)
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
implicitHeight: Math.max(Theme.menuItemHeight, keycapsCol.implicitHeight + Theme.spacingS * 2)
firstInGroup: true
lastInGroup: true
radius: Theme.groupedListOuterRadius
Accessible.name: keybindRow.modelData.label || content.getBindLabel(keybindRow.modelData)
Accessible.description: (keybindRow.modelData.allKeys ? keybindRow.modelData.allKeys.join(", ") : (keybindRow.modelData.key || "")) + " • " + (keybindRow.modelData.action || "")
onClicked: {
if (keybindRow.canExecute && KeybindsService.executeAction(keybindRow.modelData.action))
content.closeRequested();
}
RowLayout {
id: rowLayout
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingS
anchors.topMargin: Theme.spacingXS
anchors.bottomMargin: Theme.spacingXS
spacing: Theme.spacingS
// Description / Action (Leading)
StyledText {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: keybindRow.modelData.label || content.getBindLabel(keybindRow.modelData)
font.pixelSize: Theme.fontSizeSmall
color: keybindRow.contentColor
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
// DankKeycap items (Trailing - stacked vertically if multiple combos)
Column {
id: keycapsCol
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
spacing: Theme.spacingXS
Repeater {
model: keybindRow.modelData.keyCombos || (keybindRow.modelData.key ? [KeyUtils.formatKeyTokens(keybindRow.modelData.key, KeybindsService.modKey, KeybindsService.modSymbol)] : [])
Row {
anchors.right: parent ? parent.right : undefined
spacing: Theme.spacingXXS
Repeater {
model: modelData
DankKeycap {
text: modelData
textColor: Theme.primary
}
}
}
}
}
}
MouseArea {
id: rowHoverArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: keybindRow.canExecute ? Qt.PointingHandCursor : Qt.ArrowCursor
}
DankTooltipHost {
text: keybindRow.canExecute ? ((keybindRow.modelData.action || keybindRow.modelData.desc || "") + " • " + I18n.tr("Click to run", "cheatsheet action tooltip suffix")) : (keybindRow.modelData.label || keybindRow.modelData.action || keybindRow.modelData.desc || "")
target: keybindRow
hoverArea: rowHoverArea
}
}
}
property real scrollStep: 60
property var activeFlickable: rightFlickable
property bool showFloatingToggle: true
property bool floating: false
property alias searchField: searchField
property string selectedCategory: "All"
signal closeRequested
signal floatingToggleRequested
function scrollDown() {
if (!activeFlickable)
return;
let newY = activeFlickable.contentY + scrollStep;
newY = Math.min(newY, Math.max(0, 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;
}
function focusSearch() {
if (searchField) {
searchField.forceActiveFocus();
searchField.selectAll();
}
}
Shortcut {
sequence: "Ctrl+F"
onActivated: content.focusSearch()
}
Keys.onPressed: event => {
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_F) {
focusSearch();
event.accepted = true;
return;
}
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;
}
}
readonly property var categoryPriority: ({
"window": 1,
"windows": 1,
"window management": 1,
"workspace": 2,
"workspaces": 2,
"applications": 3,
"apps": 3,
"audio": 4,
"sound": 4,
"media": 5,
"brightness": 6,
"dms": 7,
"compositor": 8,
"custom": 9,
"system": 10
})
function getCategoryIcon(catName) {
if (!catName)
return "keyboard";
const lower = catName.toLowerCase();
if (lower === "all")
return "apps";
if (lower.includes("window"))
return "desktop_windows";
if (lower.includes("workspace"))
return "grid_view";
if (lower.includes("alt-tab") || lower.includes("alt_tab") || lower.includes("switcher"))
return "swap_horiz";
if (lower.includes("screenshot") || lower.includes("capture"))
return "screenshot_region";
if (lower.includes("overview"))
return "overview";
if (lower.includes("monitor") || lower.includes("display"))
return "monitor";
if (lower.includes("exec") || lower.includes("terminal") || lower.includes("spawn") || lower.includes("command"))
return "terminal";
if (lower.includes("scratchpad"))
return "sticky_note_2";
if (lower.includes("layout"))
return "view_quilt";
if (lower.includes("gap"))
return "border_inner";
if (lower.includes("tag"))
return "tag";
if (lower.includes("app"))
return "open_in_new";
if (lower.includes("audio") || lower.includes("sound") || lower.includes("media"))
return "volume_up";
if (lower.includes("bright"))
return "brightness_6";
if (lower.includes("dms"))
return "widgets";
if (lower.includes("system") || lower.includes("power"))
return "power_settings_new";
if (lower.includes("compositor"))
return "tune";
if (lower.includes("custom"))
return "edit";
if (lower.includes("other"))
return "more_horiz";
return "keyboard";
}
property var rawBinds: KeybindsService.cheatsheet.binds || ({})
function collapseSequentialBinds(list) {
if (!list || list.length < 3)
return list || [];
const numberPattern = /^(.*?)(\s+)(\d+)$/;
const clusters = {};
for (let i = 0; i < list.length; i++) {
const item = list[i];
const match = (item.label || "").match(numberPattern);
const combo = item.keyCombos && item.keyCombos[0];
if (match && combo && combo.length > 0) {
const lastToken = combo[combo.length - 1];
const num = parseInt(match[3], 10);
if (!isNaN(num) && lastToken === String(num)) {
const baseLabel = match[1];
const mods = combo.slice(0, -1);
const modSig = mods.join("+");
const clusterKey = (baseLabel + "||" + modSig).toLowerCase();
if (!clusters[clusterKey])
clusters[clusterKey] = [];
clusters[clusterKey].push({
item: item,
num: num,
baseLabel: baseLabel,
mods: mods,
index: i
});
}
}
}
const collapsedMap = {};
const skipIndices = {};
for (const cKey in clusters) {
const cluster = clusters[cKey];
if (cluster.length >= 3) {
cluster.sort((a, b) => a.num - b.num);
let isSeq = true;
for (let k = 1; k < cluster.length; k++) {
if (cluster[k].num !== cluster[k - 1].num + 1) {
isSeq = false;
break;
}
}
if (isSeq) {
const first = cluster[0];
const last = cluster[cluster.length - 1];
const rangeStr = first.num + "–" + last.num;
const combinedLabel = first.baseLabel + " " + rangeStr;
const allKeys = [];
for (let k = 0; k < cluster.length; k++) {
if (cluster[k].item.allKeys) {
for (let ak = 0; ak < cluster[k].item.allKeys.length; ak++) {
allKeys.push(cluster[k].item.allKeys[ak]);
}
} else if (cluster[k].item.key) {
allKeys.push(cluster[k].item.key);
}
if (k > 0) {
skipIndices[cluster[k].index] = true;
}
}
collapsedMap[first.index] = {
action: "",
desc: combinedLabel,
label: combinedLabel,
key: first.item.key,
allKeys: allKeys,
tokenSigs: {},
keyCombos: [[...first.mods, rangeStr]],
isRange: true
};
}
}
}
const result = [];
for (let i = 0; i < list.length; i++) {
if (collapsedMap[i]) {
result.push(collapsedMap[i]);
} else if (!skipIndices[i]) {
result.push(list[i]);
}
}
return result;
}
function generateCategories(query) {
const lowerQuery = query ? query.toLowerCase().trim() : "";
const lowerQueryWords = lowerQuery ? lowerQuery.split(/\s+/) : [];
const processed = {};
let totalCount = 0;
for (const cat in rawBinds) {
const binds = rawBinds[cat];
if (!Array.isArray(binds))
continue;
const catLower = cat.toLowerCase();
const subcatMap = {};
let hasBinds = false;
for (let i = 0; i < binds.length; i++) {
const bind = binds[i];
if (bind.hideOnOverlay)
continue;
const label = content.getBindLabel(bind);
const labelLower = label.toLowerCase();
const keyTokens = KeyUtils.formatKeyTokens(bind.key, KeybindsService.modKey, KeybindsService.modSymbol);
const tokenSig = keyTokens.join("+");
const keyLower = (bind.key || "").toLowerCase();
const descLower = (bind.desc || "").toLowerCase();
const actionLower = (bind.action || "").toLowerCase();
let matched = true;
for (let j = 0; j < lowerQueryWords.length; j++) {
const word = lowerQueryWords[j];
if (!word)
continue;
if (!keyLower.includes(word) && !labelLower.includes(word) && !descLower.includes(word) && !catLower.includes(word) && !actionLower.includes(word) && !tokenSig.toLowerCase().includes(word)) {
matched = false;
break;
}
}
if (!matched)
continue;
const subcatName = bind.subcat || "_root";
if (!subcatMap[subcatName])
subcatMap[subcatName] = {};
const isGraveAltTab = (bind.action === "next-window" || bind.action === "previous-window") && (bind.key || "").toLowerCase().includes("grave") && !bind.action.includes("filter=");
const bindAction = isGraveAltTab ? (bind.action + ' filter="app-id"') : (bind.action || "");
const groupKey = (label + "||" + bindAction).toLowerCase();
if (subcatMap[subcatName][groupKey]) {
const existing = subcatMap[subcatName][groupKey];
if (tokenSig && !existing.tokenSigs[tokenSig] && keyTokens.length > 0) {
existing.tokenSigs[tokenSig] = true;
existing.keyCombos.push(keyTokens);
existing.allKeys.push(bind.key);
}
} else {
const sigs = {};
if (tokenSig && keyTokens.length > 0)
sigs[tokenSig] = true;
subcatMap[subcatName][groupKey] = {
action: bindAction,
desc: bind.desc,
label: label,
key: bind.key,
allKeys: [bind.key],
tokenSigs: sigs,
keyCombos: keyTokens.length > 0 ? [keyTokens] : []
};
hasBinds = true;
}
}
if (hasBinds) {
const subcats = {};
let catBindCount = 0;
for (const subcatName in subcatMap) {
const groups = subcatMap[subcatName];
const rawList = [];
for (const gKey in groups) {
rawList.push(groups[gKey]);
}
const collapsedList = content.collapseSequentialBinds(rawList);
subcats[subcatName] = collapsedList;
catBindCount += collapsedList.length;
}
if (catBindCount > 0) {
processed[cat] = {
name: cat,
count: catBindCount,
subcats: subcats,
subcatKeys: Object.keys(subcats)
};
totalCount += catBindCount;
}
}
}
const keys = Object.keys(processed).sort((a, b) => {
const pA = categoryPriority[a.toLowerCase()] || 99;
const pB = categoryPriority[b.toLowerCase()] || 99;
if (pA !== pB)
return pA - pB;
return a.localeCompare(b);
});
return {
byCategory: processed,
sortedKeys: keys,
totalCount: totalCount
};
}
property string activeSearchQuery: ""
property var dataModel: generateCategories("")
Connections {
target: KeybindsService
function onCheatsheetLoaded() {
content.dataModel = content.generateCategories(content.activeSearchQuery);
}
}
Timer {
id: searchDebounce
interval: 120
repeat: false
onTriggered: {
content.activeSearchQuery = searchField.text;
content.dataModel = content.generateCategories(searchField.text);
if (searchField.text.trim() !== "" && content.selectedCategory !== "All") {
if (!content.dataModel.byCategory[content.selectedCategory])
content.selectedCategory = "All";
}
}
}
Item {
anchors.fill: parent
anchors.margins: Theme.windowInset
anchors.topMargin: 0
// Sidebar
Item {
id: sidebar
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 260
// Search Bar + Float Window Toggle at top of sidebar
RowLayout {
id: searchRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Theme.spacingS
DankSearchField {
id: searchField
Layout.fillWidth: true
placeholderText: I18n.tr("Search keybinds...", "keybinds cheatsheet search placeholder")
keyForwardTargets: [content]
onTextChanged: {
if (text.trim() === "") {
searchDebounce.stop();
content.activeSearchQuery = "";
content.dataModel = content.generateCategories("");
} else {
searchDebounce.restart();
}
}
Keys.onEscapePressed: event => {
content.closeRequested();
event.accepted = true;
}
}
DankActionButton {
visible: content.showFloatingToggle
buttonSize: Theme.iconButtonSize
iconName: content.floating ? "close_fullscreen" : "open_in_new"
tooltipText: content.floating ? I18n.tr("Dock window") : I18n.tr("Open as window")
onClicked: content.floatingToggleRequested()
Layout.alignment: Qt.AlignVCenter
}
}
DankFlickable {
id: sidebarFlickable
anchors.left: parent.left
anchors.right: parent.right
anchors.top: searchRow.bottom
anchors.bottom: parent.bottom
anchors.topMargin: Theme.spacingM
contentWidth: width
contentHeight: sidebarCol.implicitHeight
clip: true
Column {
id: sidebarCol
width: parent.width
spacing: Theme.spacingXXS
// "All" Category Tab
DankListItem {
id: allTab
width: sidebarCol.width
implicitHeight: Theme.menuItemHeight
isSelected: content.selectedCategory === "All"
surfaceColor: "transparent"
border.width: 0
radius: Theme.cornerRadiusFull
topLeftRadius: Theme.cornerRadiusFull
topRightRadius: Theme.cornerRadiusFull
bottomLeftRadius: Theme.cornerRadiusFull
bottomRightRadius: Theme.cornerRadiusFull
Accessible.name: I18n.tr("All")
onClicked: content.selectedCategory = "All"
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingS
DankIcon {
name: "apps"
size: Theme.iconSizeSmall
color: allTab.contentColor
}
StyledText {
text: I18n.tr("All")
color: allTab.contentColor
font.weight: allTab.isSelected ? Theme.fontWeightMedium : Theme.fontWeightNormal
Layout.fillWidth: true
elide: Text.ElideRight
}
DankBadge {
text: content.dataModel.totalCount.toString()
color: allTab.isSelected ? Theme.primary : Theme.surfaceVariant
textColor: allTab.isSelected ? Theme.onPrimary : Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter
}
}
}
// Category Tabs
Repeater {
model: content.dataModel.sortedKeys
DankListItem {
id: catTab
required property var modelData
readonly property string catName: modelData
readonly property var catInfo: content.dataModel.byCategory[catName]
width: sidebarCol.width
implicitHeight: Theme.menuItemHeight
isSelected: content.selectedCategory === catTab.catName
surfaceColor: "transparent"
border.width: 0
radius: Theme.cornerRadiusFull
topLeftRadius: Theme.cornerRadiusFull
topRightRadius: Theme.cornerRadiusFull
bottomLeftRadius: Theme.cornerRadiusFull
bottomRightRadius: Theme.cornerRadiusFull
Accessible.name: I18n.tr(catTab.catName)
onClicked: content.selectedCategory = catTab.catName
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingS
DankIcon {
name: content.getCategoryIcon(catTab.catName)
size: Theme.iconSizeSmall
color: catTab.contentColor
}
StyledText {
text: I18n.tr(catTab.catName)
color: catTab.contentColor
font.weight: catTab.isSelected ? Theme.fontWeightMedium : Theme.fontWeightNormal
Layout.fillWidth: true
elide: Text.ElideRight
}
DankBadge {
text: (catTab.catInfo?.count || 0).toString()
color: catTab.isSelected ? Theme.primary : Theme.surfaceVariant
textColor: catTab.isSelected ? Theme.onPrimary : Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter
}
}
}
}
}
}
}
// Main Content Area
Item {
id: mainArea
anchors.left: sidebar.right
anchors.leftMargin: Theme.spacingL
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
DankFlickable {
id: rightFlickable
anchors.fill: parent
contentWidth: width
contentHeight: contentCol.implicitHeight
clip: true
Column {
id: contentCol
width: rightFlickable.width - Theme.spacingS
spacing: Theme.spacingL
// Categories Renderer
Repeater {
model: content.selectedCategory === "All" ? content.dataModel.sortedKeys : (content.dataModel.byCategory[content.selectedCategory] ? [content.selectedCategory] : [])
Column {
id: sectionCol
width: contentCol.width
spacing: Theme.spacingM
readonly property string sectionCatName: modelData
readonly property var sectionCatInfo: content.dataModel.byCategory[sectionCatName]
// Section Header
RowLayout {
width: parent.width
spacing: Theme.spacingS
DankIcon {
name: content.getCategoryIcon(sectionCol.sectionCatName)
size: Theme.iconSizeMedium
color: Theme.primary
}
StyledText {
text: I18n.tr(sectionCol.sectionCatName)
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: Theme.primary
}
}
// Subcategories Repeater
Repeater {
model: sectionCol.sectionCatInfo?.subcatKeys || []
Column {
id: subcatCol
width: sectionCol.width
spacing: Theme.spacingXS
readonly property string subcatName: modelData
readonly property var subcatBinds: sectionCol.sectionCatInfo?.subcats?.[subcatName] || []
// Subcategory Title (if not _root)
StyledText {
visible: subcatCol.subcatName !== "_root"
text: I18n.tr(subcatCol.subcatName)
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightMedium
color: Theme.surfaceVariantText
}
readonly property bool isTwoColumn: rightFlickable.width > 550
// 2-Column Responsive Masonry Layout
RowLayout {
id: keybindsMasonry
width: parent.width
spacing: Theme.spacingM
// Left Column
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: Theme.spacingXS
Repeater {
model: subcatCol.subcatBinds ? subcatCol.subcatBinds.filter((_, idx) => !subcatCol.isTwoColumn || idx % 2 === 0) : []
delegate: keybindItemDelegate
}
}
// Right Column
ColumnLayout {
visible: subcatCol.isTwoColumn
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: Theme.spacingXS
Repeater {
model: subcatCol.isTwoColumn && subcatCol.subcatBinds ? subcatCol.subcatBinds.filter((_, idx) => idx % 2 === 1) : []
delegate: keybindItemDelegate
}
}
}
}
}
}
}
}
}
// Empty State
Column {
anchors.centerIn: parent
spacing: Theme.spacingM
visible: content.dataModel.totalCount === 0
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: content.activeSearchQuery.trim() !== "" ? "search_off" : "keyboard"
size: Theme.iconSizeLarge + Theme.spacingL
color: Theme.surfaceVariantText
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: content.activeSearchQuery.trim() !== "" ? I18n.tr("No results found") : I18n.tr("No keybinds found")
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: Theme.surfaceText
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: I18n.tr("Try a different search")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: content.activeSearchQuery.trim() !== ""
}
}
}
}
}