diff --git a/core/internal/keybinds/providers/niri.go b/core/internal/keybinds/providers/niri.go index ce23e02c..fbe8b0ed 100644 --- a/core/internal/keybinds/providers/niri.go +++ b/core/internal/keybinds/providers/niri.go @@ -333,35 +333,6 @@ func (n *NiriProvider) isRecentWindowsAction(action string) bool { } } -func (n *NiriProvider) parseSpawnArgs(s string) []string { - var args []string - var current strings.Builder - var inQuote, escaped bool - - for _, r := range s { - switch { - case escaped: - current.WriteRune(r) - escaped = false - case r == '\\': - escaped = true - case r == '"': - inQuote = !inQuote - case r == ' ' && !inQuote: - if current.Len() > 0 { - args = append(args, current.String()) - current.Reset() - } - default: - current.WriteRune(r) - } - } - if current.Len() > 0 { - args = append(args, current.String()) - } - return args -} - func (n *NiriProvider) buildBindNode(bind *overrideBind) *document.Node { node := document.NewNode() node.SetName(bind.Key) @@ -392,19 +363,48 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node { action = strings.TrimSpace(action) node := document.NewNode() - if !strings.HasPrefix(action, "spawn ") { + parts := n.parseActionParts(action) + if len(parts) == 0 { node.SetName(action) return node } - node.SetName("spawn") - args := n.parseSpawnArgs(strings.TrimPrefix(action, "spawn ")) - for _, arg := range args { + node.SetName(parts[0]) + for _, arg := range parts[1:] { node.AddArgument(arg, "") } return node } +func (n *NiriProvider) parseActionParts(action string) []string { + var parts []string + var current strings.Builder + var inQuote, escaped bool + + for _, r := range action { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '"': + inQuote = !inQuote + case r == ' ' && !inQuote: + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + if current.Len() > 0 { + parts = append(parts, current.String()) + } + return parts +} + func (n *NiriProvider) writeOverrideBinds(binds map[string]*overrideBind) error { overridePath := n.GetOverridePath() content := n.generateBindsContent(binds) @@ -501,21 +501,46 @@ func (n *NiriProvider) writeBindNode(sb *strings.Builder, bind *overrideBind, in sb.WriteString(" { ") if len(node.Children) > 0 { child := node.Children[0] - sb.WriteString(child.Name.String()) + actionName := child.Name.String() + sb.WriteString(actionName) + forceQuote := actionName == "spawn" for _, arg := range child.Arguments { sb.WriteString(" ") - n.writeQuotedArg(sb, arg.ValueString()) + n.writeArg(sb, arg.ValueString(), forceQuote) } } sb.WriteString("; }\n") } -func (n *NiriProvider) writeQuotedArg(sb *strings.Builder, val string) { +func (n *NiriProvider) writeArg(sb *strings.Builder, val string, forceQuote bool) { + if !forceQuote && n.isNumericArg(val) { + sb.WriteString(val) + return + } sb.WriteString("\"") sb.WriteString(strings.ReplaceAll(val, "\"", "\\\"")) sb.WriteString("\"") } +func (n *NiriProvider) isNumericArg(val string) bool { + if val == "" { + return false + } + start := 0 + if val[0] == '-' || val[0] == '+' { + if len(val) == 1 { + return false + } + start = 1 + } + for i := start; i < len(val); i++ { + if val[i] < '0' || val[i] > '9' { + return false + } + } + return true +} + func (n *NiriProvider) validateBindsContent(content string) error { tmpFile, err := os.CreateTemp("", "dms-binds-*.kdl") if err != nil { diff --git a/core/internal/keybinds/providers/niri_parser_test.go b/core/internal/keybinds/providers/niri_parser_test.go index b486d4ba..dfd715f4 100644 --- a/core/internal/keybinds/providers/niri_parser_test.go +++ b/core/internal/keybinds/providers/niri_parser_test.go @@ -496,3 +496,119 @@ func TestNiriParseMultipleArgs(t *testing.T) { } } } + +func TestNiriParseNumericWorkspaceBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; } + Mod+2 hotkey-overlay-title="Focus Workspace 2" { focus-workspace 2; } + Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; } + Mod+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-column-to-workspace 1; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 4 { + t.Errorf("Expected 4 keybinds, got %d", len(result.Section.Keybinds)) + } + + for _, kb := range result.Section.Keybinds { + switch kb.Key { + case "1": + if len(kb.Mods) == 1 && kb.Mods[0] == "Mod" { + if kb.Action != "focus-workspace" || len(kb.Args) != 1 || kb.Args[0] != "1" { + t.Errorf("Mod+1 action/args mismatch: %+v", kb) + } + if kb.Description != "Focus Workspace 1" { + t.Errorf("Mod+1 description = %q, want 'Focus Workspace 1'", kb.Description) + } + } + case "0": + if kb.Action != "focus-workspace" || len(kb.Args) != 1 || kb.Args[0] != "10" { + t.Errorf("Mod+0 action/args mismatch: %+v", kb) + } + } + } +} + +func TestNiriParseQuotedStringArgs(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; } + Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; } + Super+Shift+Minus hotkey-overlay-title="Adjust Window Height -10%" { set-window-height "-10%"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(result.Section.Keybinds)) + } + + for _, kb := range result.Section.Keybinds { + if kb.Action == "set-column-width" { + if len(kb.Args) != 1 { + t.Errorf("set-column-width should have 1 arg, got %d", len(kb.Args)) + continue + } + if kb.Args[0] != "-10%" && kb.Args[0] != "+10%" { + t.Errorf("set-column-width arg = %q, want -10%% or +10%%", kb.Args[0]) + } + } + } +} + +func TestNiriParseActionWithProperties(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-column-to-workspace 1 focus=false; } + Mod+Shift+2 hotkey-overlay-title="Move to Workspace 2" { move-column-to-workspace 2 focus=false; } + Alt+Tab { next-window scope="output"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(result.Section.Keybinds)) + } + + for _, kb := range result.Section.Keybinds { + switch kb.Action { + case "move-column-to-workspace": + if len(kb.Args) != 1 { + t.Errorf("move-column-to-workspace should have 1 arg, got %d", len(kb.Args)) + } + case "next-window": + if kb.Key != "Tab" { + t.Errorf("next-window key = %q, want 'Tab'", kb.Key) + } + } + } +} diff --git a/core/internal/keybinds/providers/niri_test.go b/core/internal/keybinds/providers/niri_test.go index 2408e8cf..9ac6fcd1 100644 --- a/core/internal/keybinds/providers/niri_test.go +++ b/core/internal/keybinds/providers/niri_test.go @@ -397,3 +397,211 @@ recent-windows { t.Errorf("Expected at least 2 Alt-Tab binds, got %d", len(cheatSheet.Binds["Alt-Tab"])) } } + +func TestNiriGenerateBindsContentNumericArgs(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + name string + binds map[string]*overrideBind + expected string + }{ + { + name: "workspace with numeric arg", + binds: map[string]*overrideBind{ + "Mod+1": { + Key: "Mod+1", + Action: "focus-workspace 1", + Description: "Focus Workspace 1", + }, + }, + expected: `binds { + Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; } +} +`, + }, + { + name: "workspace with large numeric arg", + binds: map[string]*overrideBind{ + "Mod+0": { + Key: "Mod+0", + Action: "focus-workspace 10", + Description: "Focus Workspace 10", + }, + }, + expected: `binds { + Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; } +} +`, + }, + { + name: "percentage string arg (should be quoted)", + binds: map[string]*overrideBind{ + "Super+Minus": { + Key: "Super+Minus", + Action: `set-column-width "-10%"`, + Description: "Adjust Column Width -10%", + }, + }, + expected: `binds { + Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; } +} +`, + }, + { + name: "positive percentage string arg", + binds: map[string]*overrideBind{ + "Super+Equal": { + Key: "Super+Equal", + Action: `set-column-width "+10%"`, + Description: "Adjust Column Width +10%", + }, + }, + expected: `binds { + Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; } +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.generateBindsContent(tt.binds) + if result != tt.expected { + t.Errorf("generateBindsContent() =\n%q\nwant:\n%q", result, tt.expected) + } + }) + } +} + +func TestNiriGenerateActionWithUnquotedPercentArg(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "Super+Equal": { + Key: "Super+Equal", + Action: "set-window-height +10%", + Description: "Adjust Window Height +10%", + }, + } + + content := provider.generateBindsContent(binds) + expected := `binds { + Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; } +} +` + if content != expected { + t.Errorf("Content mismatch.\nGot:\n%s\nWant:\n%s", content, expected) + } +} + +func TestNiriGenerateSpawnWithNumericArgs(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "XF86AudioLowerVolume": { + Key: "XF86AudioLowerVolume", + Action: `spawn "dms" "ipc" "call" "audio" "decrement" "3"`, + Options: map[string]any{"allow-when-locked": true}, + }, + } + + content := provider.generateBindsContent(binds) + expected := `binds { + XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } +} +` + if content != expected { + t.Errorf("Content mismatch.\nGot:\n%s\nWant:\n%s", content, expected) + } +} + +func TestNiriGenerateSpawnNumericArgFromCLI(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "XF86AudioLowerVolume": { + Key: "XF86AudioLowerVolume", + Action: "spawn dms ipc call audio decrement 3", + Options: map[string]any{"allow-when-locked": true}, + }, + } + + content := provider.generateBindsContent(binds) + expected := `binds { + XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } +} +` + if content != expected { + t.Errorf("Content mismatch.\nGot:\n%s\nWant:\n%s", content, expected) + } +} + +func TestNiriGenerateWorkspaceBindsRoundTrip(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "Mod+1": { + Key: "Mod+1", + Action: "focus-workspace 1", + Description: "Focus Workspace 1", + }, + "Mod+2": { + Key: "Mod+2", + Action: "focus-workspace 2", + Description: "Focus Workspace 2", + }, + "Mod+Shift+1": { + Key: "Mod+Shift+1", + Action: "move-column-to-workspace 1", + Description: "Move to Workspace 1", + }, + "Super+Minus": { + Key: "Super+Minus", + Action: "set-column-width -10%", + Description: "Adjust Column Width -10%", + }, + } + + content := provider.generateBindsContent(binds) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write temp file: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("Failed to parse generated content: %v\nContent was:\n%s", err, content) + } + + if len(result.Section.Keybinds) != 4 { + t.Errorf("Expected 4 keybinds after round-trip, got %d", len(result.Section.Keybinds)) + } + + foundFocusWS1 := false + foundMoveWS1 := false + foundSetWidth := false + + for _, kb := range result.Section.Keybinds { + switch { + case kb.Action == "focus-workspace" && len(kb.Args) > 0 && kb.Args[0] == "1": + foundFocusWS1 = true + case kb.Action == "move-column-to-workspace" && len(kb.Args) > 0 && kb.Args[0] == "1": + foundMoveWS1 = true + case kb.Action == "set-column-width" && len(kb.Args) > 0 && kb.Args[0] == "-10%": + foundSetWidth = true + } + } + + if !foundFocusWS1 { + t.Error("focus-workspace 1 not found after round-trip") + } + if !foundMoveWS1 { + t.Error("move-column-to-workspace 1 not found after round-trip") + } + if !foundSetWidth { + t.Error("set-column-width -10% not found after round-trip") + } +} diff --git a/quickshell/Common/KeyUtils.js b/quickshell/Common/KeyUtils.js index 8f59d2a0..cfa8bfd2 100644 --- a/quickshell/Common/KeyUtils.js +++ b/quickshell/Common/KeyUtils.js @@ -18,6 +18,7 @@ const KEY_MAP = { 96: "grave", 32: "space", 16777225: "Print", + 16777226: "Print", 16777220: "Return", 16777221: "Return", 16777217: "Tab", @@ -93,20 +94,20 @@ function xkbKeyFromQtKey(qk) { function modsFromEvent(mods) { var result = []; - if (mods & 0x04000000) - result.push("Ctrl"); - if (mods & 0x02000000) - result.push("Shift"); var hasAlt = mods & 0x08000000; var hasSuper = mods & 0x10000000; if (hasAlt && hasSuper) { result.push("Mod"); } else { - if (hasAlt) - result.push("Alt"); if (hasSuper) result.push("Super"); + if (hasAlt) + result.push("Alt"); } + if (mods & 0x04000000) + result.push("Ctrl"); + if (mods & 0x02000000) + result.push("Shift"); return result; } diff --git a/quickshell/Common/KeybindActions.js b/quickshell/Common/KeybindActions.js index afd0be4e..7b97acc9 100644 --- a/quickshell/Common/KeybindActions.js +++ b/quickshell/Common/KeybindActions.js @@ -34,7 +34,7 @@ const DMS_ACTIONS = [ { id: "spawn dms ipc call notepad toggle", label: "Notepad: Toggle" }, { id: "spawn dms ipc call notepad open", label: "Notepad: Open" }, { id: "spawn dms ipc call notepad close", label: "Notepad: Close" }, - { id: "spawn dms ipc call dash toggle", label: "Dashboard: Toggle" }, + { id: "spawn dms ipc call dash toggle \"\"", label: "Dashboard: Toggle" }, { id: "spawn dms ipc call dash open overview", label: "Dashboard: Overview" }, { id: "spawn dms ipc call dash open media", label: "Dashboard: Media" }, { id: "spawn dms ipc call dash open weather", label: "Dashboard: Weather" }, @@ -109,9 +109,15 @@ const COMPOSITOR_ACTIONS = { { id: "fullscreen-window", label: "Fullscreen" }, { id: "maximize-column", label: "Maximize Column" }, { id: "center-column", label: "Center Column" }, + { id: "center-visible-columns", label: "Center Visible Columns" }, { id: "toggle-window-floating", label: "Toggle Floating" }, + { id: "switch-focus-between-floating-and-tiling", label: "Switch Floating/Tiling Focus" }, { id: "switch-preset-column-width", label: "Cycle Column Width" }, { id: "switch-preset-window-height", label: "Cycle Window Height" }, + { id: "set-column-width", label: "Set Column Width" }, + { id: "set-window-height", label: "Set Window Height" }, + { id: "reset-window-height", label: "Reset Window Height" }, + { id: "expand-column-to-available-width", label: "Expand to Available Width" }, { id: "consume-or-expel-window-left", label: "Consume/Expel Left" }, { id: "consume-or-expel-window-right", label: "Consume/Expel Right" }, { id: "toggle-column-tabbed-display", label: "Toggle Tabbed" } @@ -136,8 +142,10 @@ const COMPOSITOR_ACTIONS = { { id: "focus-workspace-down", label: "Focus Workspace Down" }, { id: "focus-workspace-up", label: "Focus Workspace Up" }, { id: "focus-workspace-previous", label: "Focus Previous Workspace" }, + { id: "focus-workspace", label: "Focus Workspace (by index)" }, { id: "move-column-to-workspace-down", label: "Move to Workspace Down" }, { id: "move-column-to-workspace-up", label: "Move to Workspace Up" }, + { id: "move-column-to-workspace", label: "Move to Workspace (by index)" }, { id: "move-workspace-down", label: "Move Workspace Down" }, { id: "move-workspace-up", label: "Move Workspace Up" } ], @@ -173,6 +181,52 @@ const COMPOSITOR_ACTIONS = { const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Window", "Monitor", "Screenshot", "System", "Overview", "Alt-Tab", "Other"]; +const ACTION_ARGS = { + "set-column-width": { + args: [{ name: "value", type: "text", label: "Width", placeholder: "+10%, -10%, 50%" }] + }, + "set-window-height": { + args: [{ name: "value", type: "text", label: "Height", placeholder: "+10%, -10%, 50%" }] + }, + "focus-workspace": { + args: [{ name: "index", type: "number", label: "Workspace", placeholder: "1, 2, 3..." }] + }, + "move-column-to-workspace": { + args: [ + { name: "index", type: "number", label: "Workspace", placeholder: "1, 2, 3..." }, + { name: "focus", type: "bool", label: "Follow focus", default: false } + ] + }, + "screenshot": { + args: [{ name: "opts", type: "screenshot", label: "Options" }] + }, + "screenshot-screen": { + args: [{ name: "opts", type: "screenshot", label: "Options" }] + }, + "screenshot-window": { + args: [{ name: "opts", type: "screenshot", label: "Options" }] + } +}; + +const DMS_ACTION_ARGS = { + "audio increment": { + base: "spawn dms ipc call audio increment", + args: [{ name: "amount", type: "number", label: "Amount %", placeholder: "5", default: "" }] + }, + "audio decrement": { + base: "spawn dms ipc call audio decrement", + args: [{ name: "amount", type: "number", label: "Amount %", placeholder: "5", default: "" }] + }, + "brightness increment": { + base: "spawn dms ipc call brightness increment", + args: [{ name: "amount", type: "number", label: "Amount %", placeholder: "5", default: "" }] + }, + "brightness decrement": { + base: "spawn dms ipc call brightness decrement", + args: [{ name: "amount", type: "number", label: "Amount %", placeholder: "5", default: "" }] + } +}; + function getActionTypes() { return ACTION_TYPES; } @@ -322,3 +376,120 @@ function parseShellCommand(action) { content = content.slice(1, -1); return content.replace(/\\"/g, "\""); } + +function getActionArgConfig(action) { + if (!action) + return null; + + var baseAction = action.split(" ")[0]; + if (ACTION_ARGS[baseAction]) + return { type: "compositor", base: baseAction, config: ACTION_ARGS[baseAction] }; + + for (var key in DMS_ACTION_ARGS) { + if (action.startsWith(DMS_ACTION_ARGS[key].base)) + return { type: "dms", base: key, config: DMS_ACTION_ARGS[key] }; + } + + return null; +} + +function parseCompositorActionArgs(action) { + if (!action) + return { base: "", args: {} }; + + var parts = action.split(" "); + var base = parts[0]; + var args = {}; + + if (!ACTION_ARGS[base]) + return { base: action, args: {} }; + + var argConfig = ACTION_ARGS[base]; + var argParts = parts.slice(1); + + if (base === "move-column-to-workspace") { + for (var i = 0; i < argParts.length; i++) { + if (argParts[i] === "focus=true" || argParts[i] === "focus=false") { + args.focus = argParts[i] === "focus=true"; + } else if (!args.index) { + args.index = argParts[i]; + } + } + } else if (base.startsWith("screenshot")) { + args.opts = {}; + for (var j = 0; j < argParts.length; j += 2) { + if (j + 1 < argParts.length) + args.opts[argParts[j]] = argParts[j + 1]; + } + } else if (argParts.length > 0) { + args.value = argParts.join(" "); + } + + return { base: base, args: args }; +} + +function buildCompositorAction(base, args) { + if (!base) + return ""; + + var parts = [base]; + + if (!args || Object.keys(args).length === 0) + return base; + + if (base === "move-column-to-workspace") { + if (args.index) + parts.push(args.index); + if (args.focus === true) + parts.push("focus=true"); + else if (args.focus === false) + parts.push("focus=false"); + } else if (base.startsWith("screenshot") && args.opts) { + for (var key in args.opts) { + if (args.opts[key] !== undefined && args.opts[key] !== "") { + parts.push(key); + parts.push(args.opts[key]); + } + } + } else if (args.value) { + parts.push(args.value); + } else if (args.index) { + parts.push(args.index); + } + + return parts.join(" "); +} + +function parseDmsActionArgs(action) { + if (!action) + return { base: "", args: {} }; + + for (var key in DMS_ACTION_ARGS) { + var config = DMS_ACTION_ARGS[key]; + if (action.startsWith(config.base)) { + var rest = action.slice(config.base.length).trim(); + return { base: key, args: { amount: rest || "" } }; + } + } + + return { base: action, args: {} }; +} + +function buildDmsAction(baseKey, args) { + var config = DMS_ACTION_ARGS[baseKey]; + if (!config) + return ""; + + var action = config.base; + if (args && args.amount) + action += " " + args.amount; + + return action; +} + +function getScreenshotOptions() { + return [ + { id: "write-to-disk", label: "Save to disk", type: "bool" }, + { id: "show-pointer", label: "Show pointer", type: "bool" } + ]; +} diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index 002658c4..7d261618 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -89,12 +89,17 @@ Item { function saveNewBind(bindData) { KeybindsService.saveBind("", bindData); - showingNewBind = false; - selectedCategory = ""; _editingKey = bindData.key; expandedKey = bindData.action; } + function _onSaveSuccess() { + if (showingNewBind) { + showingNewBind = false; + selectedCategory = ""; + } + } + function scrollToTop() { flickable.contentY = 0; } @@ -121,6 +126,10 @@ Item { keybindsTab._savedScrollY = flickable.contentY; keybindsTab._preserveScroll = true; } + function onBindSaveCompleted(success) { + if (success) + keybindsTab._onSaveSuccess(); + } function onBindRemoved(key) { keybindsTab._savedScrollY = flickable.contentY; keybindsTab._preserveScroll = true; diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index dd4eee44..43421fc0 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -55,6 +55,7 @@ Singleton { signal bindsLoaded signal bindSaved(string key) + signal bindSaveCompleted(bool success) signal bindRemoved(string key) signal dmsBindsFixed @@ -118,12 +119,14 @@ Singleton { onExited: exitCode => { root.saving = false; - if (exitCode === 0) { - root.lastError = ""; - root.loadBinds(false); - } else { + if (exitCode !== 0) { console.error("[KeybindsService] Save failed with code:", exitCode); + root.bindSaveCompleted(false); + return; } + root.lastError = ""; + root.bindSaveCompleted(true); + root.loadBinds(false); } } @@ -141,12 +144,12 @@ Singleton { } onExited: exitCode => { - if (exitCode === 0) { - root.lastError = ""; - root.loadBinds(false); - } else { + if (exitCode !== 0) { console.error("[KeybindsService] Remove failed with code:", exitCode); + return; } + root.lastError = ""; + root.loadBinds(false); } } @@ -165,15 +168,15 @@ Singleton { onExited: exitCode => { root.fixing = false; - if (exitCode === 0) { - root.lastError = ""; - root.dmsBindsIncluded = true; - root.dmsBindsFixed(); - ToastService.showSuccess(I18n.tr("Binds include added"), I18n.tr("dms/binds.kdl is now included in config.kdl"), "", "keybinds"); - Qt.callLater(root.forceReload); - } else { + if (exitCode !== 0) { console.error("[KeybindsService] Fix failed with code:", exitCode); + return; } + root.lastError = ""; + root.dmsBindsIncluded = true; + root.dmsBindsFixed(); + ToastService.showSuccess(I18n.tr("Binds include added"), I18n.tr("dms/binds.kdl is now included in config.kdl"), "", "keybinds"); + Qt.callLater(root.forceReload); } } diff --git a/quickshell/Widgets/KeybindItem.qml b/quickshell/Widgets/KeybindItem.qml index 82bbdc65..64b7e09c 100644 --- a/quickshell/Widgets/KeybindItem.qml +++ b/quickshell/Widgets/KeybindItem.qml @@ -28,6 +28,7 @@ Item { property bool addingNewKey: false property bool useCustomCompositor: false property var _shortcutInhibitor: null + property bool _altShiftGhost: false readonly property bool _shortcutInhibitorAvailable: { try { @@ -61,6 +62,11 @@ Item { Component.onDestruction: _destroyShortcutInhibitor() + Component.onCompleted: { + if (isNew && isExpanded) + resetEdits(); + } + onIsExpandedChanged: { if (!isExpanded) return; @@ -86,7 +92,7 @@ Item { editDesc = bindData.desc || ""; hasChanges = false; _actionType = Actions.getActionType(editAction); - useCustomCompositor = _actionType === "compositor" && !Actions.isKnownCompositorAction(editAction); + useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(editAction); return; } } @@ -105,7 +111,7 @@ Item { editDesc = bindData.desc || ""; hasChanges = false; _actionType = Actions.getActionType(editAction); - useCustomCompositor = _actionType === "compositor" && !Actions.isKnownCompositorAction(editAction); + useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(editAction); } function startAddingNewKey() { @@ -590,37 +596,93 @@ Item { Keys.onPressed: event => { if (!root.recording) return; - if (event.key === Qt.Key_Escape) { - root.stopRecording(); - event.accepted = true; - return; - } + + event.accepted = true; switch (event.key) { case Qt.Key_Control: case Qt.Key_Shift: case Qt.Key_Alt: case Qt.Key_Meta: - event.accepted = true; return; } - const mods = KeyUtils.modsFromEvent(event.modifiers); - const key = KeyUtils.xkbKeyFromQtKey(event.key); - if (key) { - root.updateEdit({ - key: KeyUtils.formatToken(mods, key) - }); - root.stopRecording(); - event.accepted = true; + if (event.key === 0 && (event.modifiers & Qt.AltModifier)) { + root._altShiftGhost = true; + return; } + + let mods = KeyUtils.modsFromEvent(event.modifiers); + let qtKey = event.key; + + if (root._altShiftGhost && (event.modifiers & Qt.AltModifier) && !mods.includes("Shift")) { + mods.push("Shift"); + } + root._altShiftGhost = false; + + if (qtKey === Qt.Key_Backtab) { + qtKey = Qt.Key_Tab; + if (!mods.includes("Shift")) + mods.push("Shift"); + } + + const key = KeyUtils.xkbKeyFromQtKey(qtKey); + if (!key) { + console.warn("[KeybindItem] Unknown key:", event.key, "mods:", event.modifiers); + return; + } + + root.updateEdit({ + key: KeyUtils.formatToken(mods, key) + }); + root.stopRecording(); } MouseArea { anchors.fill: parent - enabled: !root.recording - cursorShape: Qt.PointingHandCursor - onClicked: root.startRecording() + hoverEnabled: true + cursorShape: root.recording ? Qt.CrossCursor : Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + + onClicked: { + if (!root.recording) + root.startRecording(); + } + + onWheel: wheel => { + if (!root.recording) + return; + + wheel.accepted = true; + + const mods = []; + if (wheel.modifiers & Qt.ControlModifier) + mods.push("Ctrl"); + if (wheel.modifiers & Qt.ShiftModifier) + mods.push("Shift"); + if (wheel.modifiers & Qt.AltModifier) + mods.push("Alt"); + if (wheel.modifiers & Qt.MetaModifier) + mods.push("Super"); + + let wheelKey = ""; + if (wheel.angleDelta.y > 0) + wheelKey = "WheelScrollUp"; + else if (wheel.angleDelta.y < 0) + wheelKey = "WheelScrollDown"; + else if (wheel.angleDelta.x > 0) + wheelKey = "WheelScrollRight"; + else if (wheel.angleDelta.x < 0) + wheelKey = "WheelScrollLeft"; + + if (!wheelKey) + return; + + root.updateEdit({ + key: KeyUtils.formatToken(mods, wheelKey) + }); + root.stopRecording(); + } } } @@ -816,6 +878,69 @@ Item { } } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingM + + property var dmsArgConfig: { + const action = root.editAction; + if (!action) + return null; + if (action.indexOf("audio increment") !== -1 || action.indexOf("audio decrement") !== -1 || action.indexOf("brightness increment") !== -1 || action.indexOf("brightness decrement") !== -1) { + const parts = action.split(" "); + const lastPart = parts[parts.length - 1]; + const hasAmount = /^\d+$/.test(lastPart); + return { + hasAmount: hasAmount, + amount: hasAmount ? lastPart : "" + }; + } + return null; + } + + visible: root._actionType === "dms" && dmsArgConfig !== null + + StyledText { + text: I18n.tr("Amount") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + Layout.preferredWidth: 60 + } + + DankTextField { + Layout.preferredWidth: 80 + Layout.preferredHeight: 40 + placeholderText: "5" + text: parent.dmsArgConfig?.amount || "" + onTextChanged: { + if (!parent.dmsArgConfig) + return; + const action = root.editAction; + const parts = action.split(" "); + const lastPart = parts[parts.length - 1]; + const hasOldAmount = /^\d+$/.test(lastPart); + if (hasOldAmount) + parts.pop(); + if (text && /^\d+$/.test(text)) + parts.push(text); + root.updateEdit({ + action: parts.join(" ") + }); + } + } + + StyledText { + text: "%" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + Item { + Layout.fillWidth: true + } + } + RowLayout { Layout.fillWidth: true spacing: Theme.spacingM @@ -898,6 +1023,154 @@ Item { } } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingM + visible: root._actionType === "compositor" && !root.useCustomCompositor && Actions.getActionArgConfig(root.editAction) + + property var argConfig: Actions.getActionArgConfig(root.editAction) + property var parsedArgs: Actions.parseCompositorActionArgs(root.editAction) + + StyledText { + text: I18n.tr("Options") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + Layout.preferredWidth: 60 + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingS + + DankTextField { + id: argValueField + Layout.fillWidth: true + Layout.preferredHeight: 40 + visible: { + const cfg = parent.parent.argConfig; + if (!cfg || !cfg.config || !cfg.config.args) + return false; + const firstArg = cfg.config.args[0]; + return firstArg && (firstArg.type === "text" || firstArg.type === "number"); + } + placeholderText: { + const cfg = parent.parent.argConfig; + if (!cfg || !cfg.config || !cfg.config.args) + return ""; + return cfg.config.args[0]?.placeholder || ""; + } + text: parent.parent.parsedArgs?.args?.value || parent.parent.parsedArgs?.args?.index || "" + onTextChanged: { + const cfg = parent.parent.argConfig; + if (!cfg) + return; + const base = parent.parent.parsedArgs?.base || root.editAction.split(" ")[0]; + const args = cfg.config.args[0]?.type === "number" ? { + index: text + } : { + value: text + }; + root.updateEdit({ + action: Actions.buildCompositorAction(base, args) + }); + } + } + + RowLayout { + visible: { + const cfg = parent.parent.argConfig; + return cfg && cfg.base === "move-column-to-workspace"; + } + spacing: Theme.spacingXS + + DankToggle { + id: focusToggle + checked: parent.parent.parent.parsedArgs?.args?.focus === true + onCheckedChanged: { + const cfg = parent.parent.parent.argConfig; + if (!cfg) + return; + const parsed = parent.parent.parent.parsedArgs; + const args = { + index: parsed?.args?.index || "", + focus: checked + }; + root.updateEdit({ + action: Actions.buildCompositorAction("move-column-to-workspace", args) + }); + } + } + + StyledText { + text: I18n.tr("Follow focus") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + + RowLayout { + visible: { + const cfg = parent.parent.argConfig; + return cfg && cfg.base && cfg.base.startsWith("screenshot"); + } + spacing: Theme.spacingM + + RowLayout { + spacing: Theme.spacingXS + + DankToggle { + id: writeToDiskToggle + checked: parent.parent.parent.parent.parsedArgs?.args?.opts?.["write-to-disk"] === "true" + onCheckedChanged: { + const parsed = parent.parent.parent.parent.parsedArgs; + const base = parsed?.base || "screenshot"; + const opts = parsed?.args?.opts || {}; + opts["write-to-disk"] = checked ? "true" : ""; + root.updateEdit({ + action: Actions.buildCompositorAction(base, { + opts: opts + }) + }); + } + } + + StyledText { + text: I18n.tr("Save") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + + RowLayout { + spacing: Theme.spacingXS + + DankToggle { + id: showPointerToggle + checked: parent.parent.parent.parent.parsedArgs?.args?.opts?.["show-pointer"] === "true" + onCheckedChanged: { + const parsed = parent.parent.parent.parent.parsedArgs; + const base = parsed?.base || "screenshot"; + const opts = parsed?.args?.opts || {}; + opts["show-pointer"] = checked ? "true" : ""; + root.updateEdit({ + action: Actions.buildCompositorAction(base, { + opts: opts + }) + }); + } + } + + StyledText { + text: I18n.tr("Pointer") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + } + } + RowLayout { Layout.fillWidth: true spacing: Theme.spacingM