From 6b141a9b06829a34e03da6fcdfc31bee88a71311 Mon Sep 17 00:00:00 2001 From: purian23 Date: Mon, 1 Jun 2026 09:31:19 -0400 Subject: [PATCH] refactor(Hyprland): updates to Lua syntax/dispatchers --- core/internal/keybinds/providers/hyprland.go | 287 +++++++++++++++++- .../keybinds/providers/hyprland_parser.go | 231 ++++++++++++-- .../providers/hyprland_parser_test.go | 73 ++++- quickshell/Widgets/KeybindItem.qml | 2 +- 4 files changed, 555 insertions(+), 38 deletions(-) diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index 1296f3dd..cca956b1 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -493,6 +493,31 @@ func splitHyprlandAction(action string) (dispatcher, params string) { return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:]) } +func isKnownHyprlandDispatcher(dispatcher string) bool { + switch dispatcher { + case "exec", "execr", "spawn", + "killactive", "forcekillactive", "closewindow", "killwindow", + "signal", "signalwindow", "togglefloating", "setfloating", "settiled", + "workspace", "renameworkspace", "fullscreen", "fullscreenstate", "fakefullscreen", + "movetoworkspace", "movetoworkspacesilent", "pseudo", "movefocus", + "movewindow", "swapwindow", "centerwindow", "togglegroup", "changegroupactive", + "movegroupwindow", "focusmonitor", "movecursortocorner", "movecursor", + "workspaceopt", "exit", "movecurrentworkspacetomonitor", "focusworkspaceoncurrentmonitor", + "moveworkspacetomonitor", "togglespecialworkspace", "forcerendererreload", + "resizeactive", "moveactive", "cyclenext", "focuswindowbyclass", "focuswindow", + "tagwindow", "toggleswallow", "submap", "pass", "sendshortcut", "sendkeystate", + "layoutmsg", "splitratio", "dpms", "movewindowpixel", "resizewindowpixel", + "swapnext", "swapactiveworkspaces", "pin", "mouse", "bringactivetotop", + "alterzorder", "focusurgentorlast", "focuscurrentorlast", "lockgroups", + "lockactivegroup", "moveintogroup", "moveoutofgroup", "movewindoworgroup", + "moveintoorcreategroup", "setignoregrouplock", "denywindowfromgroup", "event", + "global", "setprop", "forceidle": + return true + default: + return false + } +} + func firstParam(params string) (head, rest string) { params = strings.TrimSpace(params) if params == "" { @@ -551,29 +576,181 @@ func dispatcherActiveMoveResize(funcName, params string) string { ) } +func dispatcherWindowMoveResize(funcName, params string) string { + geometry, window := splitCommaParams(params) + x, y, relative, ok := xyParams(geometry) + if !ok { + return "" + } + if !isBareLuaNumber(x) || !isBareLuaNumber(y) { + return "" + } + fields := []luaField{ + luaNumberOrStringField("x", x), + luaNumberOrStringField("y", y), + luaBoolField("relative", relative), + } + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall(funcName, fields...) +} + +func splitCommaParams(params string) (left, right string) { + left = strings.TrimSpace(params) + if idx := strings.Index(left, ","); idx >= 0 { + right = strings.TrimSpace(left[idx+1:]) + left = strings.TrimSpace(left[:idx]) + } + return left, right +} + +func luaHyprctlDispatchFunction(action string) string { + return fmt.Sprintf(`function() hl.exec_cmd(%s) end`, strconv.Quote("hyprctl dispatch "+strings.TrimSpace(action))) +} + +func luaToggleActionValue(params string) string { + switch strings.ToLower(strings.TrimSpace(params)) { + case "on", "enable", "enabled", "set", "lock": + return "on" + case "off", "disable", "disabled", "unset", "unlock": + return "off" + default: + return "toggle" + } +} + +func dispatcherToggleTableCall(funcName, params string) string { + return luaDispatcherTableCall(funcName, luaStringField("action", luaToggleActionValue(params))) +} + +func dispatcherCycleNext(params string) string { + params = strings.TrimSpace(strings.ToLower(params)) + if params == "" { + return `hl.dsp.window.cycle_next()` + } + fields := []luaField{} + for _, field := range strings.Fields(params) { + switch field { + case "prev", "previous", "b": + fields = append(fields, luaBoolField("next", false)) + case "next", "f": + fields = append(fields, luaBoolField("next", true)) + case "tiled": + fields = append(fields, luaBoolField("tiled", true)) + case "floating": + fields = append(fields, luaBoolField("floating", true)) + } + } + if len(fields) == 0 { + return "" + } + return luaDispatcherTableCall("hl.dsp.window.cycle_next", fields...) +} + +func dispatcherSwapNext(params string) string { + switch strings.ToLower(strings.TrimSpace(params)) { + case "prev", "previous", "b": + return `hl.dsp.window.swap({ prev = true })` + default: + return `hl.dsp.window.swap({ next = true })` + } +} + +func dispatcherGroupActive(params string) string { + switch strings.ToLower(strings.TrimSpace(params)) { + case "f", "next", "forward": + return `hl.dsp.group.next()` + case "b", "prev", "previous", "backward": + return `hl.dsp.group.prev()` + } + if isBareLuaNumber(params) { + return luaDispatcherTableCall("hl.dsp.group.active", luaNumberOrStringField("index", params)) + } + return "" +} + +func dispatcherMoveGroupWindow(params string) string { + switch strings.ToLower(strings.TrimSpace(params)) { + case "b", "prev", "previous", "backward": + return `hl.dsp.group.move_window({ forward = false })` + default: + return `hl.dsp.group.move_window({ forward = true })` + } +} + +func dispatcherCursorMove(params string) string { + x, y, _, ok := xyParams(params) + if !ok || !isBareLuaNumber(x) || !isBareLuaNumber(y) { + return "" + } + return luaDispatcherTableCall("hl.dsp.cursor.move", luaNumberOrStringField("x", x), luaNumberOrStringField("y", y)) +} + +func dispatcherSignal(params string) string { + signal, window := firstParam(params) + if signal == "" || !isBareLuaNumber(signal) { + return "" + } + fields := []luaField{luaNumberOrStringField("signal", signal)} + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall("hl.dsp.window.signal", fields...) +} + +func dispatcherSignalWindow(params string) string { + window, rest := firstParam(params) + signal, _ := firstParam(rest) + if signal == "" || !isBareLuaNumber(signal) { + return "" + } + fields := []luaField{luaNumberOrStringField("signal", signal)} + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall("hl.dsp.window.signal", fields...) +} + +func dispatcherTagWindow(params string) string { + tag, window := firstParam(params) + if tag == "" { + return "" + } + fields := []luaField{luaStringField("tag", tag)} + if window != "" { + fields = append(fields, luaStringField("window", window)) + } + return luaDispatcherTableCall("hl.dsp.window.tag", fields...) +} + func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { dispatcher, params := splitHyprlandAction(action) switch dispatcher { case "spawn", "exec": return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true + case "execr": + return fmt.Sprintf(`hl.dsp.exec_raw(%s)`, strconv.Quote(params)), true case "killactive": return `hl.dsp.window.kill()`, true + case "forcekillactive": + return `hl.dsp.window.kill()`, true case "closewindow": if params == "" { return `hl.dsp.window.close()`, true } - return fmt.Sprintf(`hl.dsp.window.close(%s)`, strconv.Quote(params)), true + return luaDispatcherTableCall("hl.dsp.window.close", luaStringField("window", params)), true case "killwindow": if params == "" { return `hl.dsp.window.kill()`, true } - return fmt.Sprintf(`hl.dsp.window.kill(%s)`, strconv.Quote(params)), true + return luaDispatcherTableCall("hl.dsp.window.kill", luaStringField("window", params)), true case "togglefloating": - return `hl.dsp.window.float({ action = "toggle" })`, true + return dispatcherToggleTableCall("hl.dsp.window.float", "toggle"), true case "setfloating": - return `hl.dsp.window.float({ action = "set" })`, true + return dispatcherToggleTableCall("hl.dsp.window.float", "on"), true case "settiled": - return `hl.dsp.window.float({ action = "unset" })`, true + return dispatcherToggleTableCall("hl.dsp.window.float", "off"), true case "fullscreen": mode := strings.TrimSpace(params) switch mode { @@ -582,6 +759,7 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { case "1": return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true } + return luaHyprctlDispatchFunction(action), true case "fullscreenstate": internal, rest := firstParam(params) client, _ := firstParam(rest) @@ -591,10 +769,15 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { luaNumberOrStringField("client", client), ), true } + case "fakefullscreen": + return luaHyprctlDispatchFunction(action), true case "pin": if params == "" { return `hl.dsp.window.pin()`, true } + return dispatcherToggleTableCall("hl.dsp.window.pin", params), true + case "pseudo": + return dispatcherToggleTableCall("hl.dsp.window.pseudo", params), true case "centerwindow": return `hl.dsp.window.center()`, true case "resizewindow": @@ -612,14 +795,28 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { return "", false } return luaDispatcherTableCall("hl.dsp.window.swap", luaStringField("direction", params)), true + case "swapnext": + return dispatcherSwapNext(params), true case "resizeactive": if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" { return expr, true } + return luaHyprctlDispatchFunction(action), true case "moveactive": if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" { return expr, true } + return luaHyprctlDispatchFunction(action), true + case "resizewindowpixel": + if expr := dispatcherWindowMoveResize("hl.dsp.window.resize", params); expr != "" { + return expr, true + } + return luaHyprctlDispatchFunction(action), true + case "movewindowpixel": + if expr := dispatcherWindowMoveResize("hl.dsp.window.move", params); expr != "" { + return expr, true + } + return luaHyprctlDispatchFunction(action), true case "workspace": if params == "" { return "", false @@ -662,6 +859,8 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { if workspace != "" && monitor != "" { return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true } + case "workspaceopt": + return luaHyprctlDispatchFunction(action), true case "swapactiveworkspaces": monitor1, rest := firstParam(params) monitor2, _ := firstParam(rest) @@ -680,14 +879,25 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { if params != "" { return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true } + case "focuswindowbyclass": + if params != "" { + return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", "class:"+params)), true + } case "focuscurrentorlast": return `hl.dsp.focus({ last = true })`, true case "focusurgentorlast": return `hl.dsp.focus({ urgent_or_last = true })`, true + case "cyclenext": + if expr := dispatcherCycleNext(params); expr != "" { + return expr, true + } + return luaHyprctlDispatchFunction(action), true case "layoutmsg": if params != "" { return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true } + case "splitratio": + return luaHyprctlDispatchFunction(action), true case "alterzorder": mode, window := firstParam(params) if mode != "" { @@ -707,6 +917,22 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { luaStringField("value", value), ), true } + case "bringactivetotop": + return `hl.dsp.window.bring_to_top()`, true + case "toggleswallow": + return `hl.dsp.window.toggle_swallow()`, true + case "signal": + if expr := dispatcherSignal(params); expr != "" { + return expr, true + } + case "signalwindow": + if expr := dispatcherSignalWindow(params); expr != "" { + return expr, true + } + case "tagwindow": + if expr := dispatcherTagWindow(params); expr != "" { + return expr, true + } case "dpms": dpmsAction := strings.TrimSpace(params) switch dpmsAction { @@ -753,8 +979,57 @@ func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { } return luaDispatcherTableCall("hl.dsp.send_key_state", fields...), true } + case "movecursortocorner": + if params != "" && isBareLuaNumber(params) { + return luaDispatcherTableCall("hl.dsp.cursor.move_to_corner", luaNumberOrStringField("corner", params)), true + } + case "movecursor": + if expr := dispatcherCursorMove(params); expr != "" { + return expr, true + } case "togglegroup": return `hl.dsp.group.toggle()`, true + case "changegroupactive": + if expr := dispatcherGroupActive(params); expr != "" { + return expr, true + } + return luaHyprctlDispatchFunction(action), true + case "movegroupwindow": + return dispatcherMoveGroupWindow(params), true + case "moveintogroup": + if params != "" { + return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_group", params)), true + } + case "moveintoorcreategroup": + if params != "" { + return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_or_create_group", params)), true + } + case "moveoutofgroup": + if params != "" { + return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("out_of_group", params)), true + } + return luaDispatcherTableCall("hl.dsp.window.move", luaBoolField("out_of_group", true)), true + case "movewindoworgroup": + if params != "" { + return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true + } + case "lockgroups": + return dispatcherToggleTableCall("hl.dsp.group.lock", params), true + case "lockactivegroup": + return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true + case "denywindowfromgroup": + return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true + case "setignoregrouplock": + return luaHyprctlDispatchFunction(action), true + case "forcerendererreload": + return `hl.dsp.force_renderer_reload()`, true + case "forceidle": + if params != "" && isBareLuaNumber(params) { + return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true + } + } + if isKnownHyprlandDispatcher(dispatcher) { + return luaHyprctlDispatchFunction(action), true } return "", false } @@ -764,7 +1039,7 @@ func luaActionStringFromHyprlangAction(action string) string { if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok { return expr } - return fmt.Sprintf(`hl.dispatch(%s)`, strconv.Quote(action)) + return action } func luaExprToInternalAction(expr string) string { diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index a2146106..859c374c 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -882,23 +882,20 @@ func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool) return "", i, false } -// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses -// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings). +// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping at +// the next top-level comma. It handles nested calls/tables and inline functions. func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) { start = skipLuaWS(line, start) if start >= len(line) { return "", start, false } - // Find first '(' of the call (e.g. hl.dsp.exec_cmd(...) - firstParen := strings.IndexByte(line[start:], '(') - if firstParen < 0 { - return "", start, false - } - i := start + firstParen - depth := 0 + i := start + parenDepth := 0 + braceDepth := 0 + bracketDepth := 0 + functionDepth := 0 inStr := byte(0) esc := false - exprStart := start for ; i < len(line); i++ { c := line[i] if inStr != 0 { @@ -915,19 +912,66 @@ func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok boo } continue } + if c == '[' && i+1 < len(line) && line[i+1] == '[' { + if end := strings.Index(line[i+2:], "]]"); end >= 0 { + i += end + 3 + continue + } + return "", start, false + } + if luaWordAt(line, i, "function") { + functionDepth++ + i += len("function") - 1 + continue + } + if luaWordAt(line, i, "end") && functionDepth > 0 { + functionDepth-- + i += len("end") - 1 + continue + } switch c { case '"', '\'': inStr = c case '(': - depth++ + parenDepth++ case ')': - depth-- - if depth == 0 { - return strings.TrimSpace(line[exprStart : i+1]), i + 1, true + if parenDepth > 0 { + parenDepth-- + } + case '{': + braceDepth++ + case '}': + if braceDepth > 0 { + braceDepth-- + } + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case ',': + if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && functionDepth == 0 { + return strings.TrimSpace(line[start:i]), i, true } } } - return "", start, false + expr = strings.TrimSpace(line[start:i]) + return expr, i, expr != "" +} + +func luaWordAt(line string, idx int, word string) bool { + if idx < 0 || idx+len(word) > len(line) || line[idx:idx+len(word)] != word { + return false + } + before := idx == 0 || !isLuaIdentByte(line[idx-1]) + afterIdx := idx + len(word) + after := afterIdx >= len(line) || !isLuaIdentByte(line[afterIdx]) + return before && after +} + +func isLuaIdentByte(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') } // parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line. @@ -1012,19 +1056,28 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { } } return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd")) + case strings.HasPrefix(expr, "hl.dsp.exec_raw("): + return "execr", luaCallStringArgValue(expr, "hl.dsp.exec_raw") case strings.HasPrefix(expr, "hl.dispatch("): if arg := luaCallStringArgValue(expr, "hl.dispatch"); arg != "" { return splitDispatchCommand(arg) } return "", "" + case strings.Contains(expr, "hl.exec_cmd("): + if arg := luaEmbeddedCallStringArgValue(expr, "hl.exec_cmd"); strings.HasPrefix(arg, "hyprctl dispatch ") { + return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(arg, "hyprctl dispatch "))) + } case strings.HasPrefix(expr, "hl.dsp.window.close("): + if window := luaTableStringField(expr, "window"); window != "" { + return "closewindow", window + } if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" { return "closewindow", arg } return "closewindow", "" case strings.HasPrefix(expr, "hl.dsp.window.kill("): - if luaTableBoolFieldValue(expr, "force") { - return "forcekillactive", "" + if window := luaTableStringField(expr, "window"); window != "" { + return "killwindow", window } if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" { return "killwindow", arg @@ -1043,23 +1096,50 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { client := luaStringValue(luaTableScalarField(expr, "client")) return joinDispatcherParams("fullscreenstate", internal, client) case strings.HasPrefix(expr, "hl.dsp.window.float("): - switch luaTableStringField(expr, "action") { - case "set": + switch luaToggleActionToLegacy(luaTableStringField(expr, "action")) { + case "on": return "setfloating", "" - case "unset": + case "off": return "settiled", "" default: return "togglefloating", "" } + case strings.HasPrefix(expr, "hl.dsp.window.pseudo("): + action := luaToggleActionToLegacy(luaTableStringField(expr, "action")) + if action == "" || action == "toggle" { + return "pseudo", "" + } + return "pseudo", action case strings.HasPrefix(expr, "hl.dsp.window.pin("): - if action := luaTableStringField(expr, "action"); action != "" && action != "toggle" { + if action := luaToggleActionToLegacy(luaTableStringField(expr, "action")); action != "" && action != "toggle" { return "pin", action } return "pin", "" case strings.Contains(expr, "hl.dsp.window.center()"): return "centerwindow", "" + case strings.Contains(expr, "hl.dsp.window.bring_to_top()"): + return "bringactivetotop", "" + case strings.Contains(expr, "hl.dsp.window.toggle_swallow()"): + return "toggleswallow", "" case strings.Contains(expr, "hl.dsp.group.toggle()"): return "togglegroup", "" + case strings.Contains(expr, "hl.dsp.group.next()"): + return "changegroupactive", "f" + case strings.Contains(expr, "hl.dsp.group.prev()"): + return "changegroupactive", "b" + case strings.HasPrefix(expr, "hl.dsp.group.active("): + return "changegroupactive", luaStringValue(luaTableScalarField(expr, "index")) + case strings.HasPrefix(expr, "hl.dsp.group.move_window("): + if forward, ok := luaTableBoolField(expr, "forward"); ok && !forward { + return "movegroupwindow", "b" + } + return "movegroupwindow", "f" + case strings.HasPrefix(expr, "hl.dsp.group.lock_active("): + return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action")) + case strings.HasPrefix(expr, "hl.dsp.group.lock("): + return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action")) + case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("): + return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action")) case strings.HasPrefix(expr, "hl.dsp.focus("): switch { case luaTableStringField(expr, "direction") != "": @@ -1093,8 +1173,23 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { if raw, ok := luaTableBoolField(expr, "relative"); ok && !raw { prefix = "exact " } - return "moveactive", prefix + x + " " + y + params := prefix + x + " " + y + if window := luaTableStringField(expr, "window"); window != "" { + return "movewindowpixel", params + "," + window + } + return "moveactive", params + case luaTableStringField(expr, "into_group") != "": + return "moveintogroup", luaTableStringField(expr, "into_group") + case luaTableStringField(expr, "into_or_create_group") != "": + return "moveintoorcreategroup", luaTableStringField(expr, "into_or_create_group") + case luaTableBoolFieldValue(expr, "out_of_group"): + return "moveoutofgroup", "" + case luaTableStringField(expr, "out_of_group") != "": + return "moveoutofgroup", luaTableStringField(expr, "out_of_group") case luaTableStringField(expr, "direction") != "": + if luaTableBoolFieldValue(expr, "group_aware") { + return "movewindoworgroup", luaTableStringField(expr, "direction") + } return "movewindow", luaTableStringField(expr, "direction") case luaTableStringField(expr, "monitor") != "": return "movewindow", "mon:" + luaTableStringField(expr, "monitor") @@ -1123,10 +1218,41 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative { prefix = "exact " } - return "resizeactive", prefix + x + " " + y + params := prefix + x + " " + y + if window := luaTableStringField(expr, "window"); window != "" { + return "resizewindowpixel", params + "," + window + } + return "resizeactive", params } case strings.HasPrefix(expr, "hl.dsp.window.swap("): + switch { + case luaTableBoolFieldValue(expr, "next"): + return "swapnext", "" + case luaTableBoolFieldValue(expr, "prev"): + return "swapnext", "prev" + } return "swapwindow", luaTableStringField(expr, "direction") + case strings.HasPrefix(expr, "hl.dsp.window.cycle_next("): + parts := []string{} + if next, ok := luaTableBoolField(expr, "next"); ok && !next { + parts = append(parts, "prev") + } + if luaTableBoolFieldValue(expr, "tiled") { + parts = append(parts, "tiled") + } + if luaTableBoolFieldValue(expr, "floating") { + parts = append(parts, "floating") + } + return "cyclenext", strings.Join(parts, " ") + case strings.HasPrefix(expr, "hl.dsp.window.signal("): + signal := luaStringValue(luaTableScalarField(expr, "signal")) + window := luaTableStringField(expr, "window") + if window != "" { + return joinDispatcherParams("signalwindow", window, signal) + } + return "signal", signal + case strings.HasPrefix(expr, "hl.dsp.window.tag("): + return joinDispatcherParams("tagwindow", luaTableStringField(expr, "tag"), luaTableStringField(expr, "window")) case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("): mode := luaTableStringField(expr, "mode") if mode == "" { @@ -1182,12 +1308,20 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return joinDispatcherParams("sendshortcut", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "window")) case strings.HasPrefix(expr, "hl.dsp.send_key_state("): return joinDispatcherParams("sendkeystate", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "state"), luaTableStringField(expr, "window")) + case strings.HasPrefix(expr, "hl.dsp.cursor.move_to_corner("): + return "movecursortocorner", luaStringValue(luaTableScalarField(expr, "corner")) + case strings.HasPrefix(expr, "hl.dsp.cursor.move("): + return joinDispatcherParams("movecursor", luaStringValue(luaTableScalarField(expr, "x")), luaStringValue(luaTableScalarField(expr, "y"))) + case strings.Contains(expr, "hl.dsp.force_renderer_reload()"): + return "forcerendererreload", "" + case strings.HasPrefix(expr, "hl.dsp.force_idle("): + return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle") case strings.Contains(expr, "hl.dsp.exit()"): return "exit", "" default: - return "exec", "hyprctl dispatch lua:" + expr + return expr, "" } - return "exec", "hyprctl dispatch lua:" + expr + return expr, "" } func splitDispatchCommand(command string) (dispatcher, params string) { @@ -1213,6 +1347,53 @@ func joinDispatcherParams(dispatcher string, values ...string) (string, string) return dispatcher, strings.Join(parts, " ") } +func luaEmbeddedCallStringArgValue(expr, funcName string) string { + idx := strings.Index(expr, funcName+"(") + if idx < 0 { + return "" + } + return luaCallStringArgValue(expr[idx:], funcName) +} + +func luaCallScalarArgValue(callExpr, funcName string) string { + callExpr = strings.TrimSpace(callExpr) + prefix := funcName + "(" + if !strings.HasPrefix(callExpr, prefix) { + return "" + } + inner := strings.TrimSpace(callExpr[len(prefix):]) + if inner == "" { + return "" + } + if s := luaCallStringArgValue(callExpr, funcName); s != "" { + return s + } + re := regexp.MustCompile(`^-?\d+(?:\.\d+)?`) + return re.FindString(inner) +} + +func luaToggleActionToLegacy(action string) string { + switch strings.ToLower(strings.TrimSpace(action)) { + case "on", "enable", "enabled", "set", "lock": + return "on" + case "off", "disable", "disabled", "unset", "unlock": + return "off" + default: + return "toggle" + } +} + +func luaToggleActionToLockArg(action string) string { + switch luaToggleActionToLegacy(action) { + case "on": + return "lock" + case "off": + return "unlock" + default: + return "toggle" + } +} + func extractLuaCallStringArg(callExpr, funcName string) string { callExpr = strings.TrimSpace(callExpr) prefix := funcName + "(" diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index 93cd137c..1b81c6c6 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -74,15 +74,31 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) { {`hl.dispatch("workspace 2")`, "workspace", "2"}, {`hl.dispatch([[customdispatcher arg one]])`, "customdispatcher", "arg one"}, {`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"}, + {`hl.dsp.window.float({ action = "on" })`, "setfloating", ""}, + {`hl.dsp.window.close({ window = "class:^(kitty)$" })`, "closewindow", "class:^(kitty)$"}, {`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"}, {`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"}, {`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"}, + {`hl.dsp.window.move({ direction = "r", group_aware = true })`, "movewindoworgroup", "r"}, + {`hl.dsp.window.move({ into_group = "l" })`, "moveintogroup", "l"}, + {`hl.dsp.window.move({ out_of_group = true })`, "moveoutofgroup", ""}, {`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "movetoworkspacesilent", "special:magic"}, {`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "resizeactive", "-100 0"}, {`hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`, "resizeactive", "exact 1280 720"}, + {`hl.dsp.window.resize({ x = 100, y = 50, relative = true, window = "class:^(app)$" })`, "resizewindowpixel", "100 50,class:^(app)$"}, + {`hl.dsp.window.cycle_next({ next = false, tiled = true })`, "cyclenext", "prev tiled"}, + {`hl.dsp.group.next()`, "changegroupactive", "f"}, + {`hl.dsp.group.prev()`, "changegroupactive", "b"}, + {`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"}, + {`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"}, + {`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"}, + {`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"}, + {`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"}, + {`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"}, {`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"}, {`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"}, {`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"}, + {`hl.dsp.no_op()`, "hl.dsp.no_op()", ""}, } for _, tt := range tests { @@ -126,6 +142,21 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti } } +func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) { + var sb strings.Builder + writeLuaBindLine(&sb, &hyprlandOverrideBind{ + Key: "Super+u", + Action: "hl.dsp.no_op()", + Description: "Custom Lua", + }) + + want := `hl.unbind("SUPER + U") +hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })` + if got := strings.TrimSpace(sb.String()); got != want { + t.Fatalf("writeLuaBindLine() = %q, want %q", got, want) + } +} + func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { tests := []struct { action string @@ -138,6 +169,22 @@ func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { {"resizeactive exact 1280 720", `hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`}, {"dpms toggle", `hl.dsp.dpms({ action = "toggle" })`}, {"renameworkspace 1 work", `hl.dsp.workspace.rename({ workspace = "1", name = "work" })`}, + {"changegroupactive f", `hl.dsp.group.next()`}, + {"changegroupactive b", `hl.dsp.group.prev()`}, + {"changegroupactive 2", `hl.dsp.group.active({ index = 2 })`}, + {"moveintogroup l", `hl.dsp.window.move({ into_group = "l" })`}, + {"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`}, + {"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`}, + {"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`}, + {"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`}, + {"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`}, + {"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`}, + {"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`}, + {"setfloating", `hl.dsp.window.float({ action = "on" })`}, + {"settiled", `hl.dsp.window.float({ action = "off" })`}, + {"bringactivetotop", `hl.dsp.window.bring_to_top()`}, + {"toggleswallow", `hl.dsp.window.toggle_swallow()`}, + {"forceidle 300", `hl.dsp.force_idle(300)`}, } for _, tt := range tests { @@ -155,20 +202,34 @@ func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) { got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%") - want := `hl.dispatch("resizeactive exact 100% 100%")` + want := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end` if got != want { t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want) } } -func TestLuaActionStringFallsBackToDispatchWithoutHyprctl(t *testing.T) { - got := luaActionStringFromHyprlangAction("customdispatcher USER_INPUT") - want := `hl.dispatch("customdispatcher USER_INPUT")` +func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) { + line := `hl.bind("SUPER + R", function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end, { description = "Unsupported Resize" })` + got, ok := parseLuaBindOverrideLine(line) + if !ok { + t.Fatalf("expected line to parse") + } + if got.Action != "resizeactive exact 100% 100%" { + t.Fatalf("Action = %q, want resizeactive exact 100%% 100%%", got.Action) + } + if got.Description != "Unsupported Resize" { + t.Fatalf("Description = %q, want Unsupported Resize", got.Description) + } +} + +func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) { + got := luaActionStringFromHyprlangAction("hl.dsp.no_op()") + want := `hl.dsp.no_op()` if got != want { t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want) } - if strings.Contains(got, "hyprctl dispatch") { - t.Fatalf("expected hl.dispatch fallback without hyprctl dispatch wrapper, got %q", got) + if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") { + t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got) } } diff --git a/quickshell/Widgets/KeybindItem.qml b/quickshell/Widgets/KeybindItem.qml index 7f98b4f5..ddd65faf 100644 --- a/quickshell/Widgets/KeybindItem.qml +++ b/quickshell/Widgets/KeybindItem.qml @@ -1416,7 +1416,7 @@ Item { id: customCompositorField Layout.fillWidth: true Layout.preferredHeight: root._inputHeight - placeholderText: I18n.tr("e.g., focus-workspace 3, resize-column -10") + placeholderText: KeybindsService.currentProvider === "hyprland" ? I18n.tr("e.g., hl.dsp.focus({ workspace = \"3\" })") : I18n.tr("e.g., focus-workspace 3, resize-column -10") text: root._actionType === "compositor" ? root.editAction : "" onTextChanged: { if (root._actionType !== "compositor")