summaryrefslogtreecommitdiff
path: root/noctalia/.config/plugins/keybind-cheatsheet/Main.qml
diff options
context:
space:
mode:
Diffstat (limited to 'noctalia/.config/plugins/keybind-cheatsheet/Main.qml')
-rw-r--r--noctalia/.config/plugins/keybind-cheatsheet/Main.qml1740
1 files changed, 0 insertions, 1740 deletions
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();
- }
- }
-}