From 4835f660f9435fa64336daaaae1ea3cab1dfbee6 Mon Sep 17 00:00:00 2001 From: seraphim <18ycm9tx@anonaddy.me> Date: Fri, 31 Jul 2026 14:03:07 +0700 Subject: mouse --- .../.config/plugins/keybind-cheatsheet/Main.qml | 1740 -------------------- 1 file changed, 1740 deletions(-) delete mode 100644 noctalia/.config/plugins/keybind-cheatsheet/Main.qml (limited to 'noctalia/.config/plugins/keybind-cheatsheet/Main.qml') diff --git a/noctalia/.config/plugins/keybind-cheatsheet/Main.qml b/noctalia/.config/plugins/keybind-cheatsheet/Main.qml deleted file mode 100644 index fbe79e0..0000000 --- a/noctalia/.config/plugins/keybind-cheatsheet/Main.qml +++ /dev/null @@ -1,1740 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Commons -import qs.Services.UI -import qs.Services.Compositor - -Item { - id: root - property var pluginApi: null - - - Component.onCompleted: { - if (pluginApi && !parserStarted) { - checkAndParse(); - } - } - - onPluginApiChanged: { - if (pluginApi && !parserStarted) { - checkAndParse(); - } - } - - // Check if compositor changed since last parse, and re-parse if needed - function checkAndParse() { - var currentCompositor = getCurrentCompositor(); - var savedCompositor = pluginApi?.pluginSettings?.detectedCompositor || ""; - var hasData = (pluginApi?.pluginSettings?.cheatsheetData || []).length > 0; - - // Re-parse if: - // 1. No data cached yet, OR - // 2. Compositor changed since last parse - if (!hasData || currentCompositor !== savedCompositor) { - parserStarted = true; - runParser(); - } else { - parserStarted = true; // Mark as done, using cache - } - } - - // Get current compositor name - function getCurrentCompositor() { - if (CompositorService.isHyprland) return "hyprland"; - if (CompositorService.isNiri) return "niri"; - if (CompositorService.isSway) return "sway"; - if (CompositorService.isLabwc) return "labwc"; - if (CompositorService.isMango) return "mango"; - return "unknown"; - } - - // Get user-friendly message for unsupported compositors - function getUnsupportedCompositorMessage(compositor) { - var messages = { - "sway": { - short: pluginApi?.tr("error.sway-not-supported"), - detail: pluginApi?.tr("error.sway-detail") - }, - "labwc": { - short: pluginApi?.tr("error.labwc-not-supported"), - detail: pluginApi?.tr("error.labwc-detail") - }, - "unknown": { - short: pluginApi?.tr("error.unknown-compositor"), - detail: pluginApi?.tr("error.unknown-detail") - } - }; - return messages[compositor] || messages["unknown"]; - } - - property bool parserStarted: false - - // Bug 4 fix: version counter so Panel.qml can react to data updates - property int cheatsheetDataVersion: 0 - - // Memory leak prevention: cleanup on destruction - Component.onDestruction: { - clearParsingData(); - cleanupProcesses(); - } - - function cleanupProcesses() { - if (niriGlobProcess.running) niriGlobProcess.running = false; - if (niriReadProcess.running) niriReadProcess.running = false; - if (hyprGlobProcess.running) hyprGlobProcess.running = false; - if (hyprReadProcess.running) hyprReadProcess.running = false; - if (mangoGlobProcess.running) mangoGlobProcess.running = false; - if (mangoReadProcess.running) mangoReadProcess.running = false; - if (hyprDetectProcess.running) hyprDetectProcess.running = false; - if (hyprLuaReadProcess.running) hyprLuaReadProcess.running = false; - if (hyprctlBindsProcess.running) hyprctlBindsProcess.running = false; - - // Clear process buffers - niriGlobProcess.expandedFiles = []; - hyprGlobProcess.expandedFiles = []; - mangoGlobProcess.expandedFiles = []; - hyprctlChunks = []; - currentLines = []; - } - - function clearParsingData() { - filesToParse = []; - parsedFiles = {}; - accumulatedLines = []; - currentLines = []; - collectedBinds = {}; - parseDepthCounter = 0; - luaCategoryHeaders = []; - descToCategory = ({}); - prefixToCategory = []; - hyprctlChunks = []; - if (mangoGlobProcess.running) mangoGlobProcess.running = false; - if (mangoReadProcess.running) mangoReadProcess.running = false; - mangoGlobProcess.expandedFiles = []; - } - - // Refresh function - accessible from mainInstance - function refresh() { - if (!pluginApi) { - return; - } - - // Reset parserStarted to allow re-parsing - parserStarted = false; - isCurrentlyParsing = false; - - // Now run parser - parserStarted = true; - runParser(); - } - - // Recursive parsing support - property var filesToParse: [] - property var parsedFiles: ({}) - property var accumulatedLines: [] - property var currentLines: [] - property var collectedBinds: ({}) // Collect keybinds from all files - - // Memory leak prevention: recursion limits - property int maxParseDepth: 50 - property int parseDepthCounter: 0 - property bool isCurrentlyParsing: false - - function runParser() { - if (isCurrentlyParsing) { - return; - } - - isCurrentlyParsing = true; - parseDepthCounter = 0; - - // Detect compositor using CompositorService - var compositorName = getCurrentCompositor(); - if (!CompositorService.isHyprland && !CompositorService.isNiri && !CompositorService.isMango) { - isCurrentlyParsing = false; - - Logger.w("KeybindCheatsheet", "Unsupported compositor:", compositorName); - var unsupportedMsg = getUnsupportedCompositorMessage(compositorName); - saveToDb([{ - "title": pluginApi?.tr("error.unsupported-compositor"), - "binds": [ - { "keys": compositorName.toUpperCase(), "desc": unsupportedMsg.short }, - { "keys": "INFO", "desc": unsupportedMsg.detail } - ] - }]); - return; - } - - var homeDir = Quickshell.env("HOME"); - if (!homeDir) { - isCurrentlyParsing = false; - Logger.e("KeybindCheatsheet", "Cannot resolve $HOME environment variable"); - saveToDb([{ - "title": "ERROR", - "binds": [{ "keys": "ERROR", "desc": "Cannot get $HOME" }] - }]); - return; - } - - // Reset recursive state - filesToParse = []; - parsedFiles = {}; - accumulatedLines = []; - collectedBinds = {}; - - var filePath; - if (CompositorService.isHyprland) { - hyprConfPath = (pluginApi?.pluginSettings?.hyprlandConfigPath || (homeDir + "/.config/hypr/hyprland.conf")).replace(/^~/, homeDir); - hyprLuaPath = (pluginApi?.pluginSettings?.hyprlandLuaConfigPath || (homeDir + "/.config/hypr/hyprland.lua")).replace(/^~/, homeDir); - - var mode = pluginApi?.pluginSettings?.hyprlandParserMode || "auto"; - if (mode === "conf") { - startHyprlandConfTor(); - } else if (mode === "lua") { - startHyprlandLuaTor(); - } else { - // auto: prefer lua if hyprland.lua exists, else fall back to .conf - hyprDetectProcess.command = ["sh", "-c", '[ -f "$1" ] && echo lua || echo conf', "sh", hyprLuaPath]; - hyprDetectProcess.running = true; - } - return; - } - - if (CompositorService.isNiri) { - filePath = pluginApi?.pluginSettings?.niriConfigPath || (homeDir + "/.config/niri/config.kdl"); - } else if (CompositorService.isMango) { - filePath = pluginApi?.pluginSettings?.mangoConfigPath || (homeDir + "/.config/mango/config.conf"); - } - - filePath = filePath.replace(/^~/, homeDir); - filesToParse = [filePath]; - - if (CompositorService.isNiri) { - parseNextNiriFile(); - } else if (CompositorService.isMango) { - parseNextMangoFile(); - } - } - - function getDirectoryFromPath(filePath) { - var lastSlash = filePath.lastIndexOf('/'); - return lastSlash >= 0 ? filePath.substring(0, lastSlash) : "."; - } - - function resolveRelativePath(basePath, relativePath) { - var homeDir = Quickshell.env("HOME") || ""; - var resolved = relativePath.replace(/^~/, homeDir); - if (resolved.startsWith('/')) return resolved; - return getDirectoryFromPath(basePath) + "/" + resolved; - } - - function isGlobPattern(path) { - return path.indexOf('*') !== -1 || path.indexOf('?') !== -1; - } - - // ========== NIRI RECURSIVE PARSING ========== - function parseNextNiriFile() { - if (parseDepthCounter >= maxParseDepth) { - Logger.w("KeybindCheatsheet", "Niri parser hit max recursion depth (" + maxParseDepth + "), aborting"); - isCurrentlyParsing = false; - clearParsingData(); - return; - } - parseDepthCounter++; - - if (filesToParse.length === 0) { - finalizeNiriBinds(); - return; - } - - var nextFile = filesToParse.shift(); - - // Handle glob patterns - if (isGlobPattern(nextFile)) { - niriGlobProcess.expandedFiles = []; // Clear previous results - niriGlobProcess.command = ["sh", "-c", 'for f in $1; do [ -f "$f" ] && echo "$f"; done', "sh", nextFile]; - niriGlobProcess.running = true; - return; - } - - if (parsedFiles[nextFile]) { - parseNextNiriFile(); - return; - } - - parsedFiles[nextFile] = true; - currentLines = []; - niriReadProcess.currentFilePath = nextFile; - niriReadProcess.command = ["cat", nextFile]; - niriReadProcess.running = true; - } - - Process { - id: niriGlobProcess - property var expandedFiles: [] - running: false - - stdout: SplitParser { - onRead: data => { - var trimmed = data.trim(); - if (trimmed.length > 0) { - if (niriGlobProcess.expandedFiles.length < 100) { - niriGlobProcess.expandedFiles.push(trimmed); - } - } - } - } - - onExited: { - for (var i = 0; i < expandedFiles.length; i++) { - var path = expandedFiles[i]; - if (!root.parsedFiles[path] && root.filesToParse.indexOf(path) === -1) { - root.filesToParse.push(path); - } - } - expandedFiles = []; - root.parseNextNiriFile(); - } - } - - Process { - id: niriReadProcess - property string currentFilePath: "" - running: false - - stdout: SplitParser { - onRead: data => { - if (root.currentLines.length < 10000) { - root.currentLines.push(data); - } - } - } - - onExited: (exitCode, exitStatus) => { - if (exitCode === 0 && root.currentLines.length > 0) { - // First pass: find includes (Bug 1: skip commented-out lines) - for (var i = 0; i < root.currentLines.length; i++) { - var line = root.currentLines[i]; - // Bug 1 fix: skip lines starting with // (they are comments, not active includes) - if (line.trim().startsWith("//")) continue; - var includeMatch = line.match(/(?:include|source)\s+"([^"]+)"/i); - if (includeMatch) { - var includePath = includeMatch[1]; - var resolvedPath = root.resolveRelativePath(currentFilePath, includePath); - if (!root.parsedFiles[resolvedPath] && root.filesToParse.indexOf(resolvedPath) === -1) { - root.filesToParse.push(resolvedPath); - } - } - } - - // Bug 2 fix: auto-discover standard Noctalia/common keybind files from the config dir - var configDir = root.getDirectoryFromPath(currentFilePath); - var standardFiles = [ - configDir + "/keybindings.common.kdl", - configDir + "/keybindings.noctalia.kdl" - ]; - for (var s = 0; s < standardFiles.length; s++) { - var sf = standardFiles[s]; - if (!root.parsedFiles[sf] && root.filesToParse.indexOf(sf) === -1) { - // We optimistically add them; parseNextNiriFile will skip if the cat fails - root.filesToParse.push(sf); - } - } - - // Second pass: parse keybinds from this file - root.parseNiriFileContent(root.currentLines.join("\n")); - } - root.currentLines = []; - root.parseNextNiriFile(); - } - } - - function parseNiriFileContent(text) { - var lines = text.split('\n'); - var inBindsBlock = false; - var bindsBlockDepth = 0; - var currentCategory = null; - var bindsFoundInFile = 0; - - // State for multiline bind parsing - var currentBindKey = null; - var currentBindAttributes = ""; - var currentBindAction = ""; - var bindBraceDepth = 0; - - var actionCategories = { - "spawn": "Applications", - "spawn-sh": "Applications", - "focus-column": "Column Navigation", - "focus-window": "Window Focus", - "focus-workspace": "Workspace Navigation", - "move-column": "Move Columns", - "move-window": "Move Windows", - "move-column-to-workspace": "Workspace Management", - "move-window-to-workspace": "Workspace Management", - "consume-window": "Window Management", - "expel-window": "Window Management", - "close-window": "Window Management", - "fullscreen-window": "Window Management", - "maximize-column": "Column Management", - "set-column-width": "Column Width", - "switch-preset-column-width": "Column Width", - "reset-window-height": "Window Size", - "screenshot": "Screenshots", - "screenshot-window": "Screenshots", - "screenshot-screen": "Screenshots", - "power-off-monitors": "Power", - "quit": "System", - "toggle-animation": "Animations" - }; - - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - - // Skip KDL block comments: /- - if (line.startsWith("/-")) continue; - - // Find binds block start - if (!inBindsBlock && line.startsWith("binds") && line.includes("{")) { - inBindsBlock = true; - bindsBlockDepth = 1; - continue; - } - - if (!inBindsBlock) continue; - - // Category markers - support multiple formats: - // //"Category Name" - // // "Category Name" - // // #"Category Name" - // //#"Category Name" - if (line.startsWith("//")) { - var categoryMatch = line.match(/\/\/\s*#?"([^"]+)"/); - if (categoryMatch) { - currentCategory = categoryMatch[1]; - } - continue; - } - - // Skip empty lines - if (line.length === 0) continue; - - // Track braces for binds block boundary - var openBraces = (line.match(/\{/g) || []).length; - var closeBraces = (line.match(/\}/g) || []).length; - - // If we're not currently parsing a multiline bind - if (currentBindKey === null) { - // Try to match a keybind start: Mod+Key or "Mod+Key" attributes { or Mod+Key { action; } - // Bug 3 fix: support optional surrounding quotes on the key combo (e.g. "Mod+Return") - var bindStartMatch = line.match(/^"?([A-Za-z0-9_+]+)"?\s*((?:[a-z\-]+=(?:"[^"]*"|[1-9][0-9]*|true|false)\s*)*)\{(.*)$/); - - if (bindStartMatch) { - currentBindKey = bindStartMatch[1]; - currentBindAttributes = bindStartMatch[2].trim(); - var restOfLine = bindStartMatch[3]; - - // Check if the bind is complete on this line (single-line bind) - if (restOfLine.includes("}")) { - // Single-line bind: Mod+H { focus-column-left; } - currentBindAction = restOfLine.replace(/\}\s*$/, "").trim(); - finalizeBind(); - } else { - // Multiline bind starts here - currentBindAction = restOfLine.trim(); - bindBraceDepth = 1; - } - } else { - // Not a bind start, track braces for binds block - bindsBlockDepth += openBraces - closeBraces; - if (bindsBlockDepth <= 0) { - inBindsBlock = false; - } - } - } else { - // We're in a multiline bind, accumulate action content - bindBraceDepth += openBraces - closeBraces; - - if (bindBraceDepth <= 0) { - // Bind is complete - currentBindAction += " " + line.replace(/\}\s*$/, "").trim(); - finalizeBind(); - } else { - // Still in multiline bind - currentBindAction += " " + line.trim(); - } - } - } - - function finalizeBind() { - if (!currentBindKey) return; - - bindsFoundInFile++; - var action = currentBindAction.trim().replace(/;$/, "").trim(); - - var hotkeyTitle = null; - var titleMatch = currentBindAttributes.match(/hotkey-overlay-title="([^"]+)"/); - if (titleMatch) hotkeyTitle = titleMatch[1]; - - var formattedKeys = formatNiriKeyCombo(currentBindKey); - var category = currentCategory || getNiriCategory(action, actionCategories); - var description = hotkeyTitle || formatNiriAction(action); - - // Extract verb (first whitespace-separated token of action) - var verbMatch = action.match(/^([A-Za-z0-9_\-]+)/); - var verb = verbMatch ? verbMatch[1] : ""; - - // Decompose key combo into modifier set + main key for merge/filter logic - var rawParts = currentBindKey.split("+"); - var mods = []; - var mainKey = ""; - for (var p = 0; p < rawParts.length; p++) { - var rp = rawParts[p].trim(); - if (rp === "Mod" || rp === "Super" || rp === "Win" || - rp === "Ctrl" || rp === "Control" || - rp === "Alt" || rp === "Shift") { - mods.push(rp === "Mod" || rp === "Win" ? "Super" : (rp === "Control" ? "Ctrl" : rp)); - } else if (rp.length > 0) { - mainKey = rp; - } - } - - if (!collectedBinds[category]) { - collectedBinds[category] = []; - } - collectedBinds[category].push({ - "keys": formattedKeys, - "desc": description, - "_verb": verb, - "_mods": mods.join("+"), - "_mainKey": mainKey - }); - - // Reset state - currentBindKey = null; - currentBindAttributes = ""; - currentBindAction = ""; - bindBraceDepth = 0; - } - } - - function finalizeNiriBinds() { - var categoryOrder = [ - "Noctalia", "Applications", "Window Management", "Column Navigation", - "Window Focus", "Workspace Navigation", "Workspace Management", - "Move Columns", "Move Windows", "Column Management", "Column Width", - "Window Size", "Screenshots", "Power", "System", "Animations" - ]; - - var categories = []; - for (var k = 0; k < categoryOrder.length; k++) { - var catName = categoryOrder[k]; - if (collectedBinds[catName] && collectedBinds[catName].length > 0) { - categories.push({ "title": catName, "binds": collectedBinds[catName] }); - } - } - - // Add remaining categories - for (var cat in collectedBinds) { - if (categoryOrder.indexOf(cat) === -1 && collectedBinds[cat].length > 0) { - categories.push({ "title": cat, "binds": collectedBinds[cat] }); - } - } - - if (categories.length === 0) { - Logger.w("KeybindCheatsheet", "Niri parser produced no binds; check config path and binds {} block"); - } - - if (pluginApi?.pluginSettings?.mergeSequentialBinds ?? true) { - categories = mergeSequentialBindsInCategories(categories); - } - - saveToDb(categories); - isCurrentlyParsing = false; - clearParsingData(); - } - - // ========== MERGE SEQUENTIAL KEYBINDS ========== - // Detects runs of consecutive numeric binds with identical modifiers, identical - // verb, and descriptions that differ only by the integer. - // Example: Super+1..5 → "Workspace 1", "Workspace 2", ... → "Workspace 1-5" - function mergeSequentialBindsInCategories(cats) { - if (!cats || cats.length === 0) return cats; - var out = []; - for (var c = 0; c < cats.length; c++) { - var cat = cats[c]; - out.push({ "title": cat.title, "binds": mergeBindsList(cat.binds || []) }); - } - return out; - } - - function mergeBindsList(binds) { - if (!binds || binds.length < 2) return binds; - - // Build a list of merge tokens for each bind: - // token = mods + "|" + verb + "|" + descriptionTemplate - // descriptionTemplate replaces standalone integers with "%N%" - var tokens = []; - var nums = []; - for (var i = 0; i < binds.length; i++) { - var b = binds[i]; - var mainKey = b._mainKey || ""; - var asInt = parseInt(mainKey, 10); - var keyIsInteger = !isNaN(asInt) && /^\d+$/.test(mainKey); - var descTemplate = (b.desc || "").replace(/\b\d+\b/g, "%N%"); - tokens.push({ - mods: b._mods || "", - verb: b._verb || "", - descTemplate: descTemplate, - keyIsInteger: keyIsInteger, - num: keyIsInteger ? asInt : NaN, - bind: b - }); - nums.push(keyIsInteger ? asInt : NaN); - } - - var result = []; - var i = 0; - while (i < tokens.length) { - var startTok = tokens[i]; - if (!startTok.keyIsInteger) { - result.push(startTok.bind); - i++; - continue; - } - - // Try to extend a run as long as next bind has same mods/verb/descTemplate - // and num is exactly previous + 1 - var runEnd = i; - while (runEnd + 1 < tokens.length) { - var nextTok = tokens[runEnd + 1]; - if (!nextTok.keyIsInteger) break; - if (nextTok.mods !== startTok.mods) break; - if (nextTok.verb !== startTok.verb) break; - if (nextTok.descTemplate !== startTok.descTemplate) break; - if (nextTok.num !== tokens[runEnd].num + 1) break; - runEnd++; - } - - var runLen = runEnd - i + 1; - if (runLen >= 3) { - // Collapse the run into a single ranged entry. - var firstBind = startTok.bind; - var lastBind = tokens[runEnd].bind; - var lo = startTok.num; - var hi = tokens[runEnd].num; - var rangeStr = lo + "-" + hi; - - // Reconstruct keys with the range token. Keep the modifier prefix from - // the first bind exactly as displayed (it already contains " + "). - var firstKeys = firstBind.keys || ""; - var lastPlus = firstKeys.lastIndexOf(" + "); - var prefix = lastPlus >= 0 ? firstKeys.substring(0, lastPlus + 3) : ""; - var mergedKeys = prefix + rangeStr; - - // Replace the integer in the description template with the range - var mergedDesc = (firstBind.desc || "").replace(/\b\d+\b/, rangeStr); - - result.push({ - "keys": mergedKeys, - "desc": mergedDesc, - "_verb": firstBind._verb, - "_mods": firstBind._mods, - "_mainKey": rangeStr, - "_merged": true - }); - i = runEnd + 1; - } else { - result.push(startTok.bind); - i++; - } - } - return result; - } - - // ========== HYPRLAND TOR SELECTION ========== - property string hyprConfPath: "" - property string hyprLuaPath: "" - - Process { - id: hyprDetectProcess - running: false - property string result: "" - stdout: SplitParser { - onRead: data => { hyprDetectProcess.result = data.trim(); } - } - onExited: { - if (result === "lua") { - root.startHyprlandLuaTor(); - } else { - root.startHyprlandConfTor(); - } - } - } - - function startHyprlandConfTor() { - filesToParse = [hyprConfPath]; - parseNextHyprlandFile(); - } - - function startHyprlandLuaTor() { - // Read hyprland.lua + required modules to recover category headers, - // then query hyprctl for the authoritative (loop/require-expanded) binds. - luaCategoryHeaders = []; - descToCategory = ({}); - prefixToCategory = []; - filesToParse = [hyprLuaPath]; - parseNextHyprLuaFile(); - } - - // ========== HYPRLAND LUA TOR (hyprctl binds -j) ========== - property var luaCategoryHeaders: [] // ordered category titles from `-- N. NAME` - property var descToCategory: ({}) // exact description -> category - property var prefixToCategory: [] // [{prefix, category}] for loop-built binds - property var hyprctlChunks: [] - - function parseNextHyprLuaFile() { - if (parseDepthCounter >= maxParseDepth) { - runHyprctlBinds(); - return; - } - parseDepthCounter++; - - if (filesToParse.length === 0) { - runHyprctlBinds(); - return; - } - - var nextFile = filesToParse.shift(); - if (parsedFiles[nextFile]) { - parseNextHyprLuaFile(); - return; - } - parsedFiles[nextFile] = true; - currentLines = []; - hyprLuaReadProcess.currentFilePath = nextFile; - hyprLuaReadProcess.command = ["cat", nextFile]; - hyprLuaReadProcess.running = true; - } - - Process { - id: hyprLuaReadProcess - property string currentFilePath: "" - running: false - - stdout: SplitParser { - onRead: data => { - if (root.currentLines.length < 10000) root.currentLines.push(data); - } - } - - onExited: (exitCode, exitStatus) => { - if (exitCode === 0 && root.currentLines.length > 0) { - var currentCategory = null; - for (var i = 0; i < root.currentLines.length; i++) { - var line = root.currentLines[i]; - - // require("a.b.c") / require 'a/b/c' -> a/b/c.lua relative to current file. - // Lua module names use dots as path separators; literal paths (with /) and - // .lua suffixes are passed through unchanged. - var reqMatch = line.match(/require\s*\(?\s*["']([^"']+)["']/); - if (reqMatch) { - var mod = reqMatch[1]; - if (!mod.endsWith(".lua")) { - if (mod.indexOf("/") === -1) mod = mod.replace(/\./g, "/"); - mod = mod + ".lua"; - } - var resolved = root.resolveRelativePath(currentFilePath, mod); - if (!root.parsedFiles[resolved] && root.filesToParse.indexOf(resolved) === -1) { - root.filesToParse.push(resolved); - } - } - - // Category header: -- 1. NAME - var headMatch = line.match(/^\s*--\s*\d+\.\s*(.+?)\s*$/); - if (headMatch) { - currentCategory = headMatch[1].trim(); - if (root.luaCategoryHeaders.indexOf(currentCategory) === -1) { - root.luaCategoryHeaders.push(currentCategory); - } - continue; - } - - // description = "..." / desc = '...' - var dMatch = line.match(/(?:description|desc)\s*=\s*["']([^"']*)["']/); - if (dMatch && currentCategory) { - var lit = dMatch[1]; - if (line.indexOf("..") !== -1) { - // Concatenated/dynamic description (e.g. "Workspace " .. i) - var pfx = lit.trim(); - if (pfx.length > 0) root.prefixToCategory.push({ prefix: pfx, category: currentCategory }); - } else if (lit.length > 0 && root.descToCategory[lit] === undefined) { - root.descToCategory[lit] = currentCategory; - } - } - } - } - root.currentLines = []; - root.parseNextHyprLuaFile(); - } - } - - function runHyprctlBinds() { - hyprctlChunks = []; - hyprctlBindsProcess.command = ["hyprctl", "binds", "-j"]; - hyprctlBindsProcess.running = true; - } - - Process { - id: hyprctlBindsProcess - running: false - - stdout: SplitParser { - onRead: data => { - if (root.hyprctlChunks.length < 20000) root.hyprctlChunks.push(data); - } - } - - onExited: (exitCode, exitStatus) => { - if (exitCode !== 0 || root.hyprctlChunks.length === 0) { - Logger.e("keybind-cheatsheet", "hyprctl binds -j failed (exit " + exitCode + ")"); - root.hyprctlChunks = []; - root.saveToDb([{ - "title": root.pluginApi?.tr("error.unsupported-compositor"), - "binds": [{ "keys": "hyprctl", "desc": root.pluginApi?.tr("error.hyprctl-failed") }] - }]); - root.isCurrentlyParsing = false; - root.clearParsingData(); - return; - } - - var binds = []; - try { - binds = JSON.parse(root.hyprctlChunks.join("\n")); - } catch (e) { - Logger.e("keybind-cheatsheet", "hyprctl JSON parse failed: " + e); - binds = []; - } - root.hyprctlChunks = []; - root.buildCategoriesFromHyprctl(binds); - } - } - - // Hyprland modifier bitmask (see HL_MODIFIER_*) - function decodeModmask(mask) { - var m = []; - if (mask & 64) m.push("Super"); // LOGO / SUPER / META - if (mask & 1) m.push("Shift"); - if (mask & 4) m.push("Ctrl"); - if (mask & 8) m.push("Alt"); - if (mask & 16) m.push("Mod2"); - if (mask & 32) m.push("Mod3"); - if (mask & 128) m.push("Mod5"); - return m; - } - - function computeBindId(b) { - // `arg` is an unstable lua registry ref for __lua binds — exclude it there. - var disp = (b.dispatcher === "__lua") ? "__lua" : (b.dispatcher + ":" + (b.arg || "")); - var flags = (b.release ? 1 : 0) + "" + (b.mouse ? 1 : 0) + (b.longPress ? 1 : 0); - return [b.submap || "", b.modmask, b.key, flags, disp].join("|"); - } - - function categoryForDesc(desc) { - if (descToCategory[desc] !== undefined) return descToCategory[desc]; - for (var i = 0; i < prefixToCategory.length; i++) { - if (desc.indexOf(prefixToCategory[i].prefix) === 0) return prefixToCategory[i].category; - } - return null; - } - - function buildCategoriesFromHyprctl(binds) { - var overrides = pluginApi?.pluginSettings?.bindOverrides || ({}); - var showUndescribed = pluginApi?.pluginSettings?.showUndescribedBinds ?? true; - - var byCat = ({}); - var otherTitle = pluginApi?.tr("panel.other"); - var undescTitle = pluginApi?.tr("panel.undescribed"); - - for (var i = 0; i < binds.length; i++) { - var b = binds[i]; - if (!b || b.key === undefined) continue; - - var bindId = computeBindId(b); - var ov = overrides[bindId]; - if (ov && ov.hidden === true) continue; - - var rawDesc = (b.has_description && b.description) ? b.description : ""; - if (ov && ov.desc) rawDesc = ov.desc; - var undescribed = (rawDesc === ""); - - if (undescribed && !showUndescribed) continue; - - var keyName = formatSpecialKey(b.key); - if (keyName === b.key) keyName = formatSpecialKey(String(b.key).toUpperCase()); - var mods = decodeModmask(b.modmask); - var fullKey = mods.length > 0 ? (mods.join(" + ") + " + " + keyName) : keyName; - - var cat; - if (undescribed) { - cat = undescTitle; - } else { - cat = categoryForDesc(rawDesc) || otherTitle; - } - - if (!byCat[cat]) byCat[cat] = []; - byCat[cat].push({ - "keys": fullKey, - "desc": undescribed ? "" : rawDesc, - "bindId": bindId, - "undescribed": undescribed, - // Decomposed fields so mergeSequentialBindsInCategories() can collapse - // runs of consecutive numeric binds (e.g. Super+1..9). __lua binds share - // an empty verb, which is fine — descTemplate still disambiguates them. - "_mainKey": String(b.key), - "_mods": mods.join("+"), - "_verb": "" - }); - } - - // Emit categories in lua header order, then Other, then Undescribed last. - var categories = []; - for (var h = 0; h < luaCategoryHeaders.length; h++) { - var t = luaCategoryHeaders[h]; - if (byCat[t] && byCat[t].length > 0) { - categories.push({ "title": t, "binds": byCat[t] }); - delete byCat[t]; - } - } - if (byCat[otherTitle] && byCat[otherTitle].length > 0) { - categories.push({ "title": otherTitle, "binds": byCat[otherTitle] }); - delete byCat[otherTitle]; - } - var undesc = byCat[undescTitle]; - if (undesc && undesc.length > 0) delete byCat[undescTitle]; - // Any leftover categories (shouldn't normally happen) - for (var k in byCat) { - if (byCat[k] && byCat[k].length > 0) categories.push({ "title": k, "binds": byCat[k] }); - } - if (undesc && undesc.length > 0) { - categories.push({ "title": undescTitle, "binds": undesc }); - } - - // Apply the "merge sequential" setting here too — the hyprctl path previously - // skipped it, so the toggle had no effect for Hyprland (hyprctl-sourced) binds. - if (pluginApi?.pluginSettings?.mergeSequentialBinds ?? true) - categories = mergeSequentialBindsInCategories(categories); - saveToDb(categories); - isCurrentlyParsing = false; - clearParsingData(); - } - - // ========== HYPRLAND RECURSIVE PARSING ========== - function parseNextHyprlandFile() { - if (parseDepthCounter >= maxParseDepth) { - Logger.w("KeybindCheatsheet", "Hyprland parser hit max recursion depth (" + maxParseDepth + "), aborting"); - isCurrentlyParsing = false; - clearParsingData(); - return; - } - parseDepthCounter++; - - if (filesToParse.length === 0) { - if (accumulatedLines.length > 0) { - parseHyprlandConfig(accumulatedLines.join("\n")); - } else { - isCurrentlyParsing = false; - } - return; - } - - var nextFile = filesToParse.shift(); - - // Handle glob patterns - if (isGlobPattern(nextFile)) { - hyprGlobProcess.expandedFiles = []; // Clear previous results - hyprGlobProcess.command = ["sh", "-c", 'for f in $1; do [ -f "$f" ] && echo "$f"; done', "sh", nextFile]; - hyprGlobProcess.running = true; - return; - } - - if (parsedFiles[nextFile]) { - parseNextHyprlandFile(); - return; - } - - parsedFiles[nextFile] = true; - currentLines = []; - hyprReadProcess.currentFilePath = nextFile; - hyprReadProcess.command = ["cat", nextFile]; - hyprReadProcess.running = true; - } - - Process { - id: hyprGlobProcess - property var expandedFiles: [] - running: false - - stdout: SplitParser { - onRead: data => { - var trimmed = data.trim(); - if (trimmed.length > 0) { - if (hyprGlobProcess.expandedFiles.length < 100) { - hyprGlobProcess.expandedFiles.push(trimmed); - } - } - } - } - - onExited: { - for (var i = 0; i < expandedFiles.length; i++) { - var path = expandedFiles[i]; - if (!root.parsedFiles[path] && root.filesToParse.indexOf(path) === -1) { - root.filesToParse.push(path); - } - } - expandedFiles = []; - root.parseNextHyprlandFile(); - } - } - - Process { - id: hyprReadProcess - property string currentFilePath: "" - running: false - - stdout: SplitParser { - onRead: data => { - if (root.currentLines.length < 10000) { - root.currentLines.push(data); - } - } - } - - onExited: (exitCode, exitStatus) => { - if (exitCode === 0 && root.currentLines.length > 0) { - for (var i = 0; i < root.currentLines.length; i++) { - var line = root.currentLines[i]; - root.accumulatedLines.push(line); - - // Check for source directive - var sourceMatch = line.trim().match(/^source\s*=\s*(.+)$/); - if (sourceMatch) { - var sourcePath = sourceMatch[1].trim(); - var commentIdx = sourcePath.indexOf('#'); - if (commentIdx > 0) sourcePath = sourcePath.substring(0, commentIdx).trim(); - var resolvedPath = root.resolveRelativePath(currentFilePath, sourcePath); - if (!root.parsedFiles[resolvedPath] && root.filesToParse.indexOf(resolvedPath) === -1) { - root.filesToParse.push(resolvedPath); - } - } - } - } - root.currentLines = []; - root.parseNextHyprlandFile(); - } - } - - // ========== HYPRLAND PARSER ========== - function parseHyprlandConfig(text) { - var lines = text.split('\n'); - var categories = []; - var currentCategory = null; - var hasCategories = false; // Track if we found any category headers - var skippedNoDesc = 0; - - var modVar = pluginApi?.pluginSettings?.modKeyVariable || "$mod"; - var modVarUpper = modVar.toUpperCase(); - var includeUndescribed = pluginApi?.pluginSettings?.showUndescribedBinds ?? false; - - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - - // Category header: # 1. Category Name - if (line.startsWith("#") && line.match(/#\s*\d+\./)) { - hasCategories = true; // Found at least one category - if (currentCategory) { - categories.push(currentCategory); - } - var title = line.replace(/#\s*\d+\.\s*/, "").trim(); - currentCategory = { "title": title, "binds": [] }; - } - // Any bind directive (bind, bindm, binde, bindr, bindl, etc.) - else if (/^bind[a-z]*\s*=/.test(line)) { - var hasDesc = line.includes('#"'); - if (!hasDesc && !includeUndescribed) { - skippedNoDesc++; - continue; - } - - // If no categories found yet, create default category - if (!currentCategory && !hasCategories) { - var defaultCategoryName = pluginApi?.tr("default-category"); - currentCategory = { "title": defaultCategoryName, "binds": [] }; - } - - if (currentCategory) { - var description; - if (hasDesc) { - var descMatch = line.match(/#"(.*?)"$/); - description = descMatch ? descMatch[1] : "No description"; - } else { - description = ""; - } - - // Strip the trailing #"..." comment so it doesn't pollute parts splitting - var bindLine = hasDesc ? line.replace(/\s*#".*?"\s*$/, "") : line; - - var eqIdx = bindLine.indexOf("="); - if (eqIdx < 0) continue; - var rhs = bindLine.substring(eqIdx + 1); - var parts = rhs.split(','); - if (parts.length >= 2) { - var modPart = parts[0].trim().toUpperCase(); - var rawKey = parts[1].trim().toUpperCase(); - var key = formatSpecialKey(rawKey); - - var verb = parts.length >= 3 ? parts[2].trim().toLowerCase() : ""; - var param = parts.length >= 4 ? parts.slice(3).join(",").trim() : ""; - - // Build modifiers list properly - var mods = []; - if (modPart.includes(modVarUpper) || modPart.includes("SUPER")) mods.push("Super"); - if (modPart.includes("SHIFT")) mods.push("Shift"); - if (modPart.includes("CTRL") || modPart.includes("CONTROL")) mods.push("Ctrl"); - if (modPart.includes("ALT")) mods.push("Alt"); - - // Build full key string - var fullKey; - if (mods.length > 0) { - fullKey = mods.join(" + ") + " + " + key; - } else { - fullKey = key; - } - - // Auto-generate description for undescribed binds when included - if (!hasDesc && includeUndescribed) { - description = verb ? (verb + (param ? " " + param : "")) : pluginApi?.tr("panel.no-description"); - } - - currentCategory.binds.push({ - "keys": fullKey, - "desc": description, - "_verb": verb, - "_param": param, - "_mods": mods.join("+"), - "_mainKey": rawKey - }); - } - } - } - } - - if (currentCategory) { - categories.push(currentCategory); - } - - if (skippedNoDesc > 0) { - Logger.w("KeybindCheatsheet", "Skipped " + skippedNoDesc + " Hyprland binds without #\"description\" suffix (enable showUndescribedBinds to include)"); - } - - if (categories.length === 0 || categories.every(function(c) { return !c.binds || c.binds.length === 0; })) { - Logger.w("KeybindCheatsheet", "Hyprland parser produced no binds; check config path and #\"description\" annotations"); - } - - if (pluginApi?.pluginSettings?.mergeSequentialBinds ?? true) { - categories = mergeSequentialBindsInCategories(categories); - } - - saveToDb(categories); - isCurrentlyParsing = false; - clearParsingData(); - } - - function formatSpecialKey(key) { - var keyMap = { - // Audio keys (uppercase for Hyprland) - "XF86AUDIORAISEVOLUME": "Vol Up", - "XF86AUDIOLOWERVOLUME": "Vol Down", - "XF86AUDIOMUTE": "Mute", - "XF86AUDIOMICMUTE": "Mic Mute", - "XF86AUDIOPLAY": "Play", - "XF86AUDIOPAUSE": "Pause", - "XF86AUDIONEXT": "Next", - "XF86AUDIOPREV": "Prev", - "XF86AUDIOSTOP": "Stop", - "XF86AUDIOMEDIA": "Media", - // Audio keys (mixed case for Niri) - "XF86AudioRaiseVolume": "Vol Up", - "XF86AudioLowerVolume": "Vol Down", - "XF86AudioMute": "Mute", - "XF86AudioMicMute": "Mic Mute", - "XF86AudioPlay": "Play", - "XF86AudioPause": "Pause", - "XF86AudioNext": "Next", - "XF86AudioPrev": "Prev", - "XF86AudioStop": "Stop", - "XF86AudioMedia": "Media", - // Brightness keys - "XF86MONBRIGHTNESSUP": "Bright Up", - "XF86MONBRIGHTNESSDOWN": "Bright Down", - "XF86MonBrightnessUp": "Bright Up", - "XF86MonBrightnessDown": "Bright Down", - // Other common keys - "XF86CALCULATOR": "Calc", - "XF86MAIL": "Mail", - "XF86SEARCH": "Search", - "XF86EXPLORER": "Files", - "XF86WWW": "Browser", - "XF86HOMEPAGE": "Home", - "XF86FAVORITES": "Favorites", - "XF86POWEROFF": "Power", - "XF86SLEEP": "Sleep", - "XF86EJECT": "Eject", - // Print screen - "PRINT": "PrtSc", - "Print": "PrtSc", - // Navigation - "PRIOR": "PgUp", - "NEXT": "PgDn", - "Prior": "PgUp", - "Next": "PgDn", - // Mouse (for Hyprland) - "MOUSE_DOWN": "Scroll Down", - "MOUSE_UP": "Scroll Up", - "MOUSE:272": "Left Click", - "MOUSE:273": "Right Click", - "MOUSE:274": "Middle Click" - }; - return keyMap[key] || key; - } - - function formatNiriKeyCombo(combo) { - // Split by + and process each part - var parts = combo.split("+"); - var formattedParts = []; - - for (var i = 0; i < parts.length; i++) { - var part = parts[i].trim(); - if (part.length === 0) continue; - - // Map modifier names - if (part === "Mod" || part === "Super" || part === "Win") { - formattedParts.push("Super"); - } else if (part === "Ctrl" || part === "Control") { - formattedParts.push("Ctrl"); - } else if (part === "Alt") { - formattedParts.push("Alt"); - } else if (part === "Shift") { - formattedParts.push("Shift"); - } else { - // It's a key - format special keys - formattedParts.push(formatSpecialKey(part)); - } - } - - return formattedParts.join(" + "); - } - - // Map Noctalia IPC target+function to human-readable descriptions - property var noctaliaIpcLabels: ({ - "launcher toggle": "Launcher", - "launcher clipboard": "Clipboard History", - "launcher command": "Command Palette", - "launcher emoji": "Emoji Picker", - "launcher windows": "Window Switcher", - "launcher settings": "Launcher Settings", - "controlCenter toggle": "Control Center", - "settings toggle": "Settings", - "settings open": "Open Settings", - "sessionMenu toggle": "Session Menu", - "sessionMenu lock": "Lock & Session Menu", - "lockScreen lock": "Lock Screen", - "volume increase": "Volume Up", - "volume decrease": "Volume Down", - "volume muteOutput": "Mute Output", - "volume muteInput": "Mute Input", - "volume increaseInput": "Input Volume Up", - "volume decreaseInput": "Input Volume Down", - "brightness increase": "Brightness Up", - "brightness decrease": "Brightness Down", - "media playPause": "Play / Pause", - "media next": "Next Track", - "media previous": "Previous Track", - "media play": "Play", - "media pause": "Pause", - "media stop": "Stop", - "media toggle": "Media Panel", - "notifications toggleDND": "Do Not Disturb", - "notifications toggleHistory": "Notification History", - "notifications clear": "Clear Notifications", - "notifications dismissAll": "Dismiss All", - "wallpaper refresh": "Refresh Wallpaper", - "wallpaper toggle": "Wallpaper Panel", - "wallpaper toggleAutomation": "Toggle Wallpaper Automation", - "darkMode toggle": "Toggle Dark Mode", - "darkMode setDark": "Dark Mode", - "darkMode setLight": "Light Mode", - "nightLight toggle": "Toggle Night Light", - "dock toggle": "Toggle Dock", - "bar toggle": "Toggle Bar", - "desktopWidgets toggle": "Toggle Desktop Widgets", - "desktopWidgets edit": "Edit Desktop Widgets", - "calendar toggle": "Calendar", - "systemMonitor toggle": "System Monitor", - "idleInhibitor toggle": "Toggle Idle Inhibitor", - "monitors off": "Monitors Off", - "monitors on": "Monitors On", - "wifi toggle": "Toggle WiFi", - "bluetooth toggle": "Toggle Bluetooth", - "airplaneMode toggle": "Toggle Airplane Mode", - "powerProfile cycle": "Cycle Power Profile", - "battery togglePanel": "Battery Panel", - "network togglePanel": "Network Panel" - }) - - function formatNiriAction(action) { - // Detect Noctalia IPC commands in two formats: - // 1. spawn-sh "qs -c noctalia-shell ipc call target function" - // 2. spawn "qs" "-c" "noctalia-shell" "ipc" "call" "target" "function" - if (action.indexOf("noctalia-shell") !== -1 && action.indexOf("ipc") !== -1) { - // Extract target and function from either format: - // spawn-sh: ipc call target function (words separated by spaces) - // spawn multi-arg: "ipc" "call" "target" "function" (words wrapped in quotes) - var ipcMatch = action.match(/ipc\s+call\s+(\w+)\s+(\w+)/) || - action.match(/"ipc"\s+"call"\s+"(\w+)"\s+"(\w+)"/); - if (ipcMatch) { - var ipcKey = ipcMatch[1] + " " + ipcMatch[2]; - if (noctaliaIpcLabels[ipcKey]) { - return noctaliaIpcLabels[ipcKey]; - } - // Fallback: format target + function nicely - return ipcMatch[1].replace(/([A-Z])/g, ' $1').trim() + ": " + - ipcMatch[2].replace(/([A-Z])/g, ' $1').trim(); - } - } - - // Handle spawn and spawn-sh commands - if (action.startsWith("spawn")) { - var spawnMatch = action.match(/spawn(?:-sh)?\s+"([^"]+)"/); - if (spawnMatch) { - return "Run: " + spawnMatch[1]; - } - return action; - } - // Format action name: focus-column-left -> Focus Column Left - return action.replace(/-/g, ' ').replace(/\b\w/g, function(l) { return l.toUpperCase(); }); - } - - function getNiriCategory(action, actionCategories) { - // Noctalia IPC commands get their own category - if (action.indexOf("noctalia-shell") !== -1 && action.indexOf("ipc") !== -1) { - return "Noctalia"; - } - for (var prefix in actionCategories) { - if (action.startsWith(prefix)) { - return actionCategories[prefix]; - } - } - return "Other"; - } - - function saveToDb(data) { - if (pluginApi) { - var compositor = getCurrentCompositor(); - pluginApi.pluginSettings.cheatsheetData = data; - pluginApi.pluginSettings.detectedCompositor = compositor; - pluginApi.saveSettings(); - // Bug 4 fix: bump version counter so Panel.qml re-evaluates its data binding - cheatsheetDataVersion++; - } - } - - // ========== MANGO LOOKUP TABLES ========== - readonly property var mangoKeyNameMap: ({ - "Return": "Enter", "return": "Enter", - "equal": "=", "minus": "-", "plus": "+", - "space": "Space", "comma": ",", "period": ".", - "semicolon": ";", "apostrophe": "'", "grave": "`", - "slash": "/", "backslash": "\\", - "bracketleft": "[", "bracketright": "]", - "Escape": "Esc", "escape": "Esc" - }) - - readonly property var mangoAxisMap: ({ - "UP": "Scroll Up", "DOWN": "Scroll Down", - "LEFT": "Scroll Left", "RIGHT": "Scroll Right" - }) - - readonly property var mangoButtonMap: ({ - "BTN_LEFT": "Left Click", "BTN_RIGHT": "Right Click", - "BTN_MIDDLE": "Middle Click", "BTN_SIDE": "Mouse Side", - "BTN_EXTRA": "Mouse Extra" - }) - - readonly property var mangoNoArgActions: ({ - "killclient": "Close window", - "togglefullscreen": "Toggle fullscreen", - "togglemaximizescreen": "Maximize", - "togglefloating": "Toggle floating", - "reload_config": "Reload config", - "toggleoverview": "Toggle overview", - "quit": "Quit compositor", - "switch_proportion_preset": "Cycle column width", - "switch_keyboard_layout": "Switch keyboard layout", - "zoom": "Zoom", - "restart": "Restart", - "incnmaster": "Increase masters", - "switch_layout": "Switch layout" - }) - - readonly property var mangoDirActions: ({ - "focusdir": "Focus", - "exchange_client": "Swap", - "focusmon": "Focus Monitor", - "tagmon": "Send to Monitor" - }) - - // ========== MANGO RECURSIVE PARSING ========== - function parseNextMangoFile() { - if (parseDepthCounter >= maxParseDepth) { - Logger.w("KeybindCheatsheet", "Mango parser hit max recursion depth (" + maxParseDepth + "), aborting"); - isCurrentlyParsing = false; - clearParsingData(); - return; - } - parseDepthCounter++; - - if (filesToParse.length === 0) { - finalizeMangoBinds(); - return; - } - - var nextFile = filesToParse.shift(); - - // Handle glob patterns - if (isGlobPattern(nextFile)) { - mangoGlobProcess.expandedFiles = []; - mangoGlobProcess.command = ["sh", "-c", 'for f in $1; do [ -f "$f" ] && echo "$f"; done', "sh", nextFile]; - mangoGlobProcess.running = true; - return; - } - - if (parsedFiles[nextFile]) { - parseNextMangoFile(); - return; - } - - parsedFiles[nextFile] = true; - currentLines = []; - mangoReadProcess.currentFilePath = nextFile; - mangoReadProcess.command = ["cat", nextFile]; - mangoReadProcess.running = true; - } - - Process { - id: mangoGlobProcess - property var expandedFiles: [] - running: false - - stdout: SplitParser { - onRead: data => { - var trimmed = data.trim(); - if (trimmed.length > 0) { - if (mangoGlobProcess.expandedFiles.length < 100) { - mangoGlobProcess.expandedFiles.push(trimmed); - } - } - } - } - - onExited: { - for (var i = 0; i < expandedFiles.length; i++) { - var path = expandedFiles[i]; - if (!root.parsedFiles[path] && root.filesToParse.indexOf(path) === -1) { - root.filesToParse.push(path); - } - } - expandedFiles = []; - root.parseNextMangoFile(); - } - } - - Process { - id: mangoReadProcess - property string currentFilePath: "" - running: false - - stdout: SplitParser { - onRead: data => { - if (root.currentLines.length < 10000) { - root.currentLines.push(data); - } - } - } - - onExited: (exitCode, exitStatus) => { - if (exitCode === 0 && root.currentLines.length > 0) { - // First pass: find source/source-optional includes - for (var i = 0; i < root.currentLines.length; i++) { - var line = root.currentLines[i].trim(); - // Skip commented lines - var commentPos = root.findMangoUnquotedComment(line); - var effectiveLine = commentPos >= 0 ? line.substring(0, commentPos).trim() : line; - - var srcMatch = effectiveLine.match(/^source-optional\s*=\s*(.+)$/) || - effectiveLine.match(/^source\s*=\s*(.+)$/); - if (srcMatch) { - var srcPath = srcMatch[1].trim(); - var resolvedSrc = root.resolveRelativePath(currentFilePath, srcPath); - if (isGlobPattern(resolvedSrc)) { - if (root.filesToParse.indexOf(resolvedSrc) === -1) { - root.filesToParse.push(resolvedSrc); - } - } else if (!root.parsedFiles[resolvedSrc] && root.filesToParse.indexOf(resolvedSrc) === -1) { - root.filesToParse.push(resolvedSrc); - } - } - } - // Second pass: parse keybinds from this file - root.parseMangoConfig(root.currentLines.join("\n")); - } - root.currentLines = []; - root.parseNextMangoFile(); - } - } - - function findMangoUnquotedComment(str) { - var inSingle = false; - var inDouble = false; - for (var i = 0; i < str.length; i++) { - var ch = str[i]; - if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } - if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } - if (ch === '#' && !inSingle && !inDouble) { - // Per-bind description suffix: #"description" — not a comment - if (str.charAt(i + 1) === '"') continue; - return i; - } - } - return -1; - } - - function parseMangoConfig(text) { - var lines = text.split('\n'); - var currentCategory = null; - var defaultCat = pluginApi?.tr("default-category"); - - for (var i = 0; i < lines.length; i++) { - var rawLine = lines[i]; - var commentPos = findMangoUnquotedComment(rawLine); - var effectiveLine = commentPos >= 0 ? rawLine.substring(0, commentPos).trim() : rawLine.trim(); - - // Pure-comment lines: only candidate for category detection (# Title) - if (effectiveLine.length === 0) { - var categoryCandidate = extractMangoCategory(rawLine); - if (categoryCandidate !== null) { - currentCategory = categoryCandidate; - } - continue; - } - - // Extract optional per-bind description: trailing #"description" - var perBindDesc = null; - var descMatch = effectiveLine.match(/#"([^"]*)"\s*$/); - if (descMatch) { - perBindDesc = descMatch[1]; - effectiveLine = effectiveLine.substring(0, descMatch.index).trim(); - if (effectiveLine.length === 0) continue; - } - - // bind=MODS,KEY,ACTION,ARGS (also `binds=` — keysym-based variant - // used by layout-aware setups like AZERTY) - var bindMatch = effectiveLine.match(/^binds?\s*=\s*(.+)$/); - if (bindMatch) { - var parts = bindMatch[1].split(','); - if (parts.length >= 3) { - var modStr = parts[0].trim(); - var key = parts[1].trim(); - var action = parts[2].trim(); - var args = parts.length >= 4 ? parts.slice(3).join(',').trim() : ""; - var cat = currentCategory || defaultCat; - var formattedKeys = formatMangoKeyCombo(modStr, key, "bind"); - var description = perBindDesc || formatMangoAction(action, args); - - if (!collectedBinds[cat]) collectedBinds[cat] = []; - collectedBinds[cat].push({ "keys": formattedKeys, "desc": description }); - } - continue; - } - - // axisbind=MODS,AXIS,ACTION,ARGS (also `axisbinds=` keysym variant) - var axisMatch = effectiveLine.match(/^axisbinds?\s*=\s*(.+)$/); - if (axisMatch) { - var axisParts = axisMatch[1].split(','); - if (axisParts.length >= 3) { - var aModStr = axisParts[0].trim(); - var axis = axisParts[1].trim().toUpperCase(); - var aAction = axisParts[2].trim(); - var aArgs = axisParts.length >= 4 ? axisParts.slice(3).join(',').trim() : ""; - var aCat = currentCategory || defaultCat; - var aKey = mangoAxisMap[axis] || axis; - var aCombo = formatMangoKeyCombo(aModStr, aKey, "axis"); - var aDesc = perBindDesc || formatMangoAction(aAction, aArgs); - - if (!collectedBinds[aCat]) collectedBinds[aCat] = []; - collectedBinds[aCat].push({ "keys": aCombo, "desc": aDesc }); - } - continue; - } - - // mousebind=MODS,BTN,ACTION,ARGS (also `mousebinds=` keysym variant) - var mouseMatch = effectiveLine.match(/^mousebinds?\s*=\s*(.+)$/); - if (mouseMatch) { - var mouseParts = mouseMatch[1].split(','); - if (mouseParts.length >= 3) { - var mModStr = mouseParts[0].trim(); - var btn = mouseParts[1].trim().toUpperCase(); - var mAction = mouseParts[2].trim(); - var mArgs = mouseParts.length >= 4 ? mouseParts.slice(3).join(',').trim() : ""; - var mCat = currentCategory || defaultCat; - var mKey = mangoButtonMap[btn] || btn; - var mCombo = formatMangoKeyCombo(mModStr, mKey, "mouse"); - var mDesc = perBindDesc || formatMangoAction(mAction, mArgs); - - if (!collectedBinds[mCat]) collectedBinds[mCat] = []; - collectedBinds[mCat].push({ "keys": mCombo, "desc": mDesc }); - } - } - } - } - - function extractMangoCategory(line) { - var trimmed = line.trim(); - // A category line is a standalone comment: # Category Name - if (!trimmed.startsWith('#')) return null; - // Strip leading hashes (#, ##, ###, ...) - var rest = trimmed.replace(/^#+/, '').trim(); - if (rest.length === 0) return null; - - // Length cap — categories shouldn't be paragraphs ("# TODO fix later" etc.) - if (rest.length > 100) return null; - - // Pure horizontal rule (only decorative chars): ────, ====, ----, **** - if (/^[─━═=\-_*#/\\\s]+$/.test(rest)) return null; - - // Reject paren/bracket continuation: "(continued)", "(see above)", "[note]" - if (/^[\(\[]/.test(rest)) return null; - - // Reject flow arrows: "→ next step", "->", "=>" - if (/^[→←↑↓⇒⇐➜➔►▶]/.test(rest)) return null; - if (rest.indexOf('->') === 0 || rest.indexOf('=>') === 0) return null; - - // Reject keyword-prefixed notes: "# TODO ...", "# FIXME ...", "# NOTE ..." - if (/^(TODO|FIXME|NOTE|HACK|XXX|BUG|WIP)\b/i.test(rest)) return null; - - // Strip trailing horizontal-rule decoration: "Category ────" -> "Category" - rest = rest.replace(/[─━═=\-_*]{3,}\s*$/, '').trim(); - if (rest.length === 0) return null; - - // Numbered list extraction: "1. Foo" -> "Foo" - var numbered = rest.match(/^\d+\.\s*(.+)$/); - if (numbered) return numbered[1].trim(); - - return rest; - } - - function formatMangoKeyCombo(modStr, key, kind) { - var mods = []; - var modUpper = modStr.toUpperCase(); - // SUPER aliases: SUPER, LOGO - if (modUpper.indexOf("SUPER") !== -1 || modUpper.indexOf("LOGO") !== -1) mods.push("Super"); - if (modUpper.indexOf("SHIFT") !== -1) mods.push("Shift"); - if (modUpper.indexOf("CTRL") !== -1 || modUpper.indexOf("CONTROL") !== -1) mods.push("Ctrl"); - // ALT aliases: ALT, MOD1 - if (modUpper.indexOf("ALT") !== -1 || modUpper.indexOf("MOD1") !== -1) mods.push("Alt"); - - // Map the key name. For bind keys, prefer XF86 special-key formatting (Vol Up, Mute, ...). - var mappedKey; - if (kind === "bind") { - var specialMapped = formatSpecialKey(key); - mappedKey = (specialMapped !== key) ? specialMapped : (mangoKeyNameMap[key] || key); - } else { - mappedKey = key; - } - - if (mods.length > 0) { - return mods.join(" + ") + " + " + mappedKey; - } - return mappedKey; - } - - function formatMangoAction(action, args) { - // Check Noctalia IPC calls: spawn_shell with qs ipc call ... - if (args.indexOf("noctalia-shell") !== -1 && args.indexOf("ipc") !== -1) { - var ipcMatch = args.match(/ipc\s+call\s+(\w+)\s+(\w+)/); - if (ipcMatch) { - var ipcKey = ipcMatch[1] + " " + ipcMatch[2]; - if (noctaliaIpcLabels[ipcKey]) return noctaliaIpcLabels[ipcKey]; - return ipcMatch[1] + ": " + ipcMatch[2]; - } - } - - // No-arg actions - if (mangoNoArgActions[action]) return mangoNoArgActions[action]; - - // Direction actions - if (mangoDirActions[action]) { - var dir = args ? args.toUpperCase() : ""; - return mangoDirActions[action] + (dir ? " " + dir : ""); - } - - // spawn / spawn_shell - if (action === "spawn" || action === "spawn_shell") { - return args ? "Run: " + args : "Run command"; - } - - // workspace / move to workspace - if (action === "workspace") return args ? "Workspace " + args : "Switch workspace"; - if (action === "movetoworkspace") return args ? "Move to workspace " + args : "Move to workspace"; - if (action === "movetoworkspacesilent") return args ? "Move (silent) to workspace " + args : "Move to workspace"; - - // Generic fallback: convert snake_case / camelCase to readable - return action.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim() - .replace(/\b\w/g, function(l) { return l.toUpperCase(); }); - } - - function finalizeMangoBinds() { - var categories = []; - for (var cat in collectedBinds) { - if (collectedBinds[cat].length > 0) { - categories.push({ "title": cat, "binds": collectedBinds[cat] }); - } - } - - if (categories.length === 0) { - Logger.w("KeybindCheatsheet", "Mango parser produced no binds; check config path and bind= directives"); - } - - saveToDb(categories); - isCurrentlyParsing = false; - clearParsingData(); - } - - IpcHandler { - target: "plugin:keybind-cheatsheet" - - function toggle() { - if (root.pluginApi) { - root.pluginApi.withCurrentScreen(screen => { - root.pluginApi.togglePanel(screen); - }); - } - } - - function refresh() { - root.refresh(); - } - } -} -- cgit v1.2.3