diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index 9d0ee673..1296f3dd 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -764,7 +764,7 @@ func luaActionStringFromHyprlangAction(action string) string { if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok { return expr } - return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action)) + return fmt.Sprintf(`hl.dispatch(%s)`, strconv.Quote(action)) } func luaExprToInternalAction(expr string) string { @@ -786,7 +786,7 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string { if strings.Contains(bind.Flags, "e") { opts = append(opts, "repeating = true") } - if bind.Description != "" && strings.Contains(bind.Flags, "d") { + if bind.Description != "" { opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description))) } return opts @@ -806,11 +806,7 @@ func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { if len(opts) > 0 { fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", ")) } else { - if bind.Description != "" { - fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description) - } else { - fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr) - } + fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr) } sb.WriteByte('\n') } @@ -829,6 +825,9 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) { action := luaExprToInternalAction(actionExpr) flags := luaBindOptFlags(optSuffix) description := luaBindOptDescription(optSuffix) + if description == "" { + description = luaLineTrailingComment(line) + } return &hyprlandOverrideBind{ Key: internalKey, Action: action, diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index fb2cd06c..a2146106 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -1006,17 +1006,17 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { if arg != "" { if u, err := strconv.Unquote(arg); err == nil { if strings.HasPrefix(u, "hyprctl dispatch ") { - rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch ")) - parts := strings.SplitN(rest, " ", 2) - if len(parts) == 1 { - return parts[0], "" - } - return parts[0], parts[1] + return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))) } return "exec", u } } return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd")) + case strings.HasPrefix(expr, "hl.dispatch("): + if arg := luaCallStringArgValue(expr, "hl.dispatch"); arg != "" { + return splitDispatchCommand(arg) + } + return "", "" case strings.HasPrefix(expr, "hl.dsp.window.close("): if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" { return "closewindow", arg @@ -1190,6 +1190,18 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) { return "exec", "hyprctl dispatch lua:" + expr } +func splitDispatchCommand(command string) (dispatcher, params string) { + command = strings.TrimSpace(command) + if command == "" { + return "", "" + } + parts := strings.SplitN(command, " ", 2) + if len(parts) == 1 { + return parts[0], "" + } + return parts[0], strings.TrimSpace(parts[1]) +} + func joinDispatcherParams(dispatcher string, values ...string) (string, string) { parts := make([]string, 0, len(values)) for _, value := range values { @@ -1300,8 +1312,38 @@ func luaStringValue(raw string) string { } func luaLineTrailingComment(line string) string { - if idx := strings.Index(line, "--"); idx >= 0 { - return strings.TrimSpace(line[idx+2:]) + inString := byte(0) + escaped := false + for i := 0; i < len(line)-1; i++ { + c := line[i] + if inString != 0 { + if escaped { + escaped = false + continue + } + if c == '\\' && inString == '"' { + escaped = true + continue + } + if c == inString { + inString = 0 + } + continue + } + if c == '"' || c == '\'' { + inString = c + continue + } + if c == '[' && line[i+1] == '[' { + if end := strings.Index(line[i+2:], "]]"); end >= 0 { + i += end + 3 + continue + } + return "" + } + if c == '-' && line[i+1] == '-' { + return strings.TrimSpace(line[i+2:]) + } } return "" } diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index 2446fbe8..93cd137c 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -71,6 +71,8 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) { }{ {`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`}, {`hl.dsp.exec_cmd([[hyprctl dispatch workspace 1]])`, "workspace", "1"}, + {`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.focus({ workspace = "e+1" })`, "workspace", "e+1"}, {`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"}, @@ -118,7 +120,7 @@ func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) { }) want := `hl.unbind("SUPER + N") -hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle` +hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { description = "Notepad: Toggle" })` if got := strings.TrimSpace(sb.String()); got != want { t.Fatalf("writeLuaBindLine() = %q, want %q", got, want) } @@ -153,12 +155,50 @@ func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) { got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%") - want := `hl.dsp.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%")` + want := `hl.dispatch("resizeactive exact 100% 100%")` 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")` + 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) + } +} + +func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) { + tmpDir := t.TempDir() + overridePath := filepath.Join(tmpDir, "binds-user.lua") + contents := `hl.unbind("SUPER + N") +hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle +hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help")) +` + if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + + binds, err := readLuaOrHyprlangOverride(overridePath) + if err != nil { + t.Fatal(err) + } + got := binds["super+n"] + if got == nil { + t.Fatalf("expected SUPER+N override, got %#v", binds) + } + if got.Description != "Notepad: Toggle" { + t.Fatalf("expected trailing comment to be preserved as description, got %q", got.Description) + } + if got := binds["super+h"]; got == nil || got.Description != "" { + t.Fatalf("expected -- inside a Lua string to stay out of the description, got %#v", got) + } +} + func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) { tmpDir := t.TempDir() dmsDir := filepath.Join(tmpDir, "dms") diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index d273507f..eac8ab2f 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -403,6 +403,7 @@ Singleton { const sourceStr = bind.source || "config"; const keyData = { "key": bind.key || "", + "desc": bind.desc || "", "source": sourceStr, "isOverride": sourceStr === "dms", "isDMSManaged": sourceStr === "dms" || sourceStr === "dms-default", diff --git a/quickshell/Widgets/KeybindItem.qml b/quickshell/Widgets/KeybindItem.qml index b86f239b..7f98b4f5 100644 --- a/quickshell/Widgets/KeybindItem.qml +++ b/quickshell/Widgets/KeybindItem.qml @@ -55,6 +55,7 @@ Item { readonly property var configConflict: bindData.conflict || null readonly property bool hasConfigConflict: configConflict !== null readonly property string _originalKey: editingKeyIndex >= 0 && editingKeyIndex < keys.length ? keys[editingKeyIndex].key : "" + readonly property string _selectedDesc: editingKeyIndex >= 0 && editingKeyIndex < keys.length ? (keys[editingKeyIndex].desc || bindData.desc || "") : (bindData.desc || "") readonly property var _conflicts: editKey ? KeyUtils.getConflictingBinds(editKey, bindData.action, KeybindsService.getFlatBinds()) : [] readonly property bool hasConflict: _conflicts.length > 0 @@ -100,7 +101,7 @@ Item { editingKeyIndex = i; editKey = keyToFind; editAction = bindData.action || ""; - editDesc = bindData.desc || ""; + editDesc = keys[i].desc || bindData.desc || ""; if (_savedCooldownMs >= 0) { editCooldownMs = _savedCooldownMs; _savedCooldownMs = -1; @@ -149,7 +150,7 @@ Item { editingKeyIndex = keys.length > 0 ? 0 : -1; editKey = editingKeyIndex >= 0 ? keys[editingKeyIndex].key : ""; editAction = bindData.action || ""; - editDesc = bindData.desc || ""; + editDesc = editingKeyIndex >= 0 ? (keys[editingKeyIndex].desc || bindData.desc || "") : (bindData.desc || ""); editCooldownMs = editingKeyIndex >= 0 ? (keys[editingKeyIndex].cooldownMs || 0) : 0; editFlags = editingKeyIndex >= 0 ? (keys[editingKeyIndex].flags || "") : ""; editAllowWhenLocked = editingKeyIndex >= 0 ? (keys[editingKeyIndex].allowWhenLocked || false) : false; @@ -177,6 +178,7 @@ Item { addingNewKey = false; editingKeyIndex = index; editKey = keys[index].key; + editDesc = keys[index].desc || bindData.desc || ""; editCooldownMs = keys[index].cooldownMs || 0; editFlags = keys[index].flags || ""; editAllowWhenLocked = keys[index].allowWhenLocked || false; @@ -206,12 +208,13 @@ Item { editAllowInhibiting = changes.allowInhibiting; const hasKey = editingKeyIndex >= 0 && editingKeyIndex < keys.length; const origKey = hasKey ? keys[editingKeyIndex].key : ""; + const origDesc = hasKey ? (keys[editingKeyIndex].desc || bindData.desc || "") : (bindData.desc || ""); const origCooldown = hasKey ? (keys[editingKeyIndex].cooldownMs || 0) : 0; const origFlags = hasKey ? (keys[editingKeyIndex].flags || "") : ""; const origAllowWhenLocked = hasKey ? (keys[editingKeyIndex].allowWhenLocked || false) : false; const origRepeat = hasKey ? keys[editingKeyIndex].repeat : undefined; const origAllowInhibiting = hasKey ? keys[editingKeyIndex].allowInhibiting : undefined; - hasChanges = editKey !== origKey || editAction !== (bindData.action || "") || editDesc !== (bindData.desc || "") || editCooldownMs !== origCooldown || editFlags !== origFlags || editAllowWhenLocked !== origAllowWhenLocked || editRepeat !== origRepeat || editAllowInhibiting !== origAllowInhibiting; + hasChanges = editKey !== origKey || editAction !== (bindData.action || "") || editDesc !== origDesc || editCooldownMs !== origCooldown || editFlags !== origFlags || editAllowWhenLocked !== origAllowWhenLocked || editRepeat !== origRepeat || editAllowInhibiting !== origAllowInhibiting; } function canSave() { @@ -353,7 +356,7 @@ Item { spacing: 2 StyledText { - text: root.bindData.desc || root.bindData.action || I18n.tr("No action") + text: root.isExpanded ? (root._selectedDesc || root.bindData.action || I18n.tr("No action")) : (root.bindData.desc || root.bindData.action || I18n.tr("No action")) font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceText elide: Text.ElideRight @@ -1246,6 +1249,18 @@ Item { } placeholderText: optionsRow.argConfig?.config?.args[0]?.placeholder || "" + function commitValue(textValue) { + const cfg = optionsRow.argConfig; + if (!cfg) + return; + const parsed = optionsRow.parsedArgs; + const args = Object.assign({}, parsed?.args || {}); + args[_argName] = textValue; + root.updateEdit({ + "action": Actions.buildCompositorAction(KeybindsService.currentProvider, parsed?.base || cfg.base, args) + }); + } + Connections { target: optionsRow function onParsedArgsChanged() { @@ -1260,17 +1275,7 @@ Item { text = optionsRow.parsedArgs?.args[_argName] || ""; } - onEditingFinished: { - const cfg = optionsRow.argConfig; - if (!cfg) - return; - const parsed = optionsRow.parsedArgs; - const args = Object.assign({}, parsed?.args || {}); - args[_argName] = text; - root.updateEdit({ - "action": Actions.buildCompositorAction(KeybindsService.currentProvider, parsed?.base || cfg.base, args) - }); - } + onTextChanged: commitValue(text) } RowLayout {