1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

keyboard shortcuts: comprehensive keyboard shortcut management interface

- niri only for now
- requires quickshell-git, hidden otherwise
- Add, Edit, Delete keybinds
- Large suite of pre-defined and custom actions
- Works with niri 25.11+ include feature
This commit is contained in:
bbedward
2025-12-02 23:08:23 -05:00
parent a679be68b1
commit f92dc6f71b
17 changed files with 4105 additions and 863 deletions

View File

@@ -3,14 +3,19 @@ package providers
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
)
type NiriProvider struct {
configDir string
configDir string
dmsBindsIncluded bool
parsed bool
}
func NewNiriProvider(configDir string) *NiriProvider {
@@ -23,8 +28,7 @@ func NewNiriProvider(configDir string) *NiriProvider {
}
func defaultNiriConfigDir() string {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome != "" {
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
return filepath.Join(configHome, "niri")
}
@@ -40,21 +44,40 @@ func (n *NiriProvider) Name() string {
}
func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
section, err := ParseNiriKeys(n.configDir)
result, err := ParseNiriKeys(n.configDir)
if err != nil {
return nil, fmt.Errorf("failed to parse niri config: %w", err)
}
n.dmsBindsIncluded = result.DMSBindsIncluded
n.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind)
n.convertSection(section, "", categorizedBinds)
n.convertSection(result.Section, "", categorizedBinds)
return &keybinds.CheatSheet{
Title: "Niri Keybinds",
Provider: n.Name(),
Binds: categorizedBinds,
Title: "Niri Keybinds",
Provider: n.Name(),
Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded,
}, nil
}
func (n *NiriProvider) HasDMSBindsIncluded() bool {
if n.parsed {
return n.dmsBindsIncluded
}
result, err := ParseNiriKeys(n.configDir)
if err != nil {
return false
}
n.dmsBindsIncluded = result.DMSBindsIncluded
n.parsed = true
return n.dmsBindsIncluded
}
func (n *NiriProvider) convertSection(section *NiriSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
currentSubcat := subcategory
if section.Name != "" {
@@ -106,19 +129,19 @@ func (n *NiriProvider) categorizeByAction(action string) string {
}
func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string) keybinds.Keybind {
key := n.formatKey(kb)
desc := kb.Description
rawAction := n.formatRawAction(kb.Action, kb.Args)
if desc == "" {
desc = rawAction
source := "config"
if strings.Contains(kb.Source, "dms/binds.kdl") {
source = "dms"
}
return keybinds.Keybind{
Key: key,
Description: desc,
Key: n.formatKey(kb),
Description: kb.Description,
Action: rawAction,
Subcategory: subcategory,
Source: source,
}
}
@@ -126,6 +149,15 @@ func (n *NiriProvider) formatRawAction(action string, args []string) string {
if len(args) == 0 {
return action
}
if action == "spawn" && len(args) >= 3 && args[1] == "-c" {
switch args[0] {
case "sh", "bash":
cmd := strings.Join(args[2:], " ")
return fmt.Sprintf("spawn %s -c \"%s\"", args[0], strings.ReplaceAll(cmd, "\"", "\\\""))
}
}
return action + " " + strings.Join(args, " ")
}
@@ -135,3 +167,308 @@ func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string {
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (n *NiriProvider) GetOverridePath() string {
return filepath.Join(n.configDir, "dms", "binds.kdl")
}
func (n *NiriProvider) validateAction(action string) error {
action = strings.TrimSpace(action)
switch {
case action == "":
return fmt.Errorf("action cannot be empty")
case action == "spawn" || action == "spawn ":
return fmt.Errorf("spawn command requires arguments")
case strings.HasPrefix(action, "spawn "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn "))
switch rest {
case "":
return fmt.Errorf("spawn command requires arguments")
case "sh -c \"\"", "sh -c ''", "bash -c \"\"", "bash -c ''":
return fmt.Errorf("shell command cannot be empty")
}
}
return nil
}
func (n *NiriProvider) SetBind(key, action, description string, options map[string]any) error {
if err := n.validateAction(action); err != nil {
return err
}
overridePath := n.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
existingBinds, err := n.loadOverrideBinds()
if err != nil {
existingBinds = make(map[string]*overrideBind)
}
existingBinds[key] = &overrideBind{
Key: key,
Action: action,
Description: description,
Options: options,
}
return n.writeOverrideBinds(existingBinds)
}
func (n *NiriProvider) RemoveBind(key string) error {
existingBinds, err := n.loadOverrideBinds()
if err != nil {
return nil
}
delete(existingBinds, key)
return n.writeOverrideBinds(existingBinds)
}
type overrideBind struct {
Key string
Action string
Description string
Options map[string]any
}
func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
overridePath := n.GetOverridePath()
binds := make(map[string]*overrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
parser := NewNiriParser(filepath.Dir(overridePath))
parser.currentSource = overridePath
doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil {
return nil, err
}
for _, node := range doc.Nodes {
if node.Name.String() != "binds" || node.Children == nil {
continue
}
for _, child := range node.Children {
kb := parser.parseKeybindNode(child, "")
if kb == nil {
continue
}
keyStr := parser.formatBindKey(kb)
binds[keyStr] = &overrideBind{
Key: keyStr,
Action: n.formatRawAction(kb.Action, kb.Args),
Description: kb.Description,
Options: n.extractOptions(child),
}
}
}
return binds, nil
}
func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
if node.Properties == nil {
return make(map[string]any)
}
opts := make(map[string]any)
if val, ok := node.Properties.Get("repeat"); ok {
opts["repeat"] = val.String() == "true"
}
if val, ok := node.Properties.Get("cooldown-ms"); ok {
opts["cooldown-ms"] = val.String()
}
if val, ok := node.Properties.Get("allow-when-locked"); ok {
opts["allow-when-locked"] = val.String() == "true"
}
return opts
}
func (n *NiriProvider) isRecentWindowsAction(action string) bool {
switch action {
case "next-window", "previous-window":
return true
default:
return false
}
}
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)
if bind.Options != nil {
if v, ok := bind.Options["repeat"]; ok && v == false {
node.AddProperty("repeat", false, "")
}
if v, ok := bind.Options["cooldown-ms"]; ok {
node.AddProperty("cooldown-ms", v, "")
}
if v, ok := bind.Options["allow-when-locked"]; ok && v == true {
node.AddProperty("allow-when-locked", true, "")
}
}
if bind.Description != "" {
node.AddProperty("hotkey-overlay-title", bind.Description, "")
}
actionNode := n.buildActionNode(bind.Action)
node.AddNode(actionNode)
return node
}
func (n *NiriProvider) buildActionNode(action string) *document.Node {
action = strings.TrimSpace(action)
node := document.NewNode()
if !strings.HasPrefix(action, "spawn ") {
node.SetName(action)
return node
}
node.SetName("spawn")
args := n.parseSpawnArgs(strings.TrimPrefix(action, "spawn "))
for _, arg := range args {
node.AddArgument(arg, "")
}
return node
}
func (n *NiriProvider) writeOverrideBinds(binds map[string]*overrideBind) error {
overridePath := n.GetOverridePath()
content := n.generateBindsContent(binds)
if err := n.validateBindsContent(content); err != nil {
return err
}
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string {
if len(binds) == 0 {
return "binds {}\n"
}
var regularBinds, recentWindowsBinds []*overrideBind
for _, bind := range binds {
switch {
case n.isRecentWindowsAction(bind.Action):
recentWindowsBinds = append(recentWindowsBinds, bind)
default:
regularBinds = append(regularBinds, bind)
}
}
var sb strings.Builder
sb.WriteString("binds {\n")
for _, bind := range regularBinds {
n.writeBindNode(&sb, bind, " ")
}
sb.WriteString("}\n")
if len(recentWindowsBinds) > 0 {
sb.WriteString("\nrecent-windows {\n")
sb.WriteString(" binds {\n")
for _, bind := range recentWindowsBinds {
n.writeBindNode(&sb, bind, " ")
}
sb.WriteString(" }\n")
sb.WriteString("}\n")
}
return sb.String()
}
func (n *NiriProvider) writeBindNode(sb *strings.Builder, bind *overrideBind, indent string) {
node := n.buildBindNode(bind)
sb.WriteString(indent)
sb.WriteString(node.Name.String())
if node.Properties.Exist() {
sb.WriteString(" ")
sb.WriteString(strings.TrimLeft(node.Properties.String(), " "))
}
sb.WriteString(" { ")
if len(node.Children) > 0 {
child := node.Children[0]
sb.WriteString(child.Name.String())
for _, arg := range child.Arguments {
sb.WriteString(" ")
n.writeQuotedArg(sb, arg.ValueString())
}
}
sb.WriteString("; }\n")
}
func (n *NiriProvider) writeQuotedArg(sb *strings.Builder, val string) {
sb.WriteString("\"")
sb.WriteString(strings.ReplaceAll(val, "\"", "\\\""))
sb.WriteString("\"")
}
func (n *NiriProvider) validateBindsContent(content string) error {
tmpFile, err := os.CreateTemp("", "dms-binds-*.kdl")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(content); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write temp file: %w", err)
}
tmpFile.Close()
cmd := exec.Command("niri", "validate", "-c", tmpFile.Name())
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("invalid config: %s", strings.TrimSpace(string(output)))
}
return nil
}

View File

@@ -16,6 +16,7 @@ type NiriKeyBinding struct {
Action string
Args []string
Description string
Source string
}
type NiriSection struct {
@@ -25,10 +26,12 @@ type NiriSection struct {
}
type NiriParser struct {
configDir string
processedFiles map[string]bool
bindMap map[string]*NiriKeyBinding
bindOrder []string
configDir string
processedFiles map[string]bool
bindMap map[string]*NiriKeyBinding
bindOrder []string
currentSource string
dmsBindsIncluded bool
}
func NewNiriParser(configDir string) *NiriParser {
@@ -37,6 +40,7 @@ func NewNiriParser(configDir string) *NiriParser {
processedFiles: make(map[string]bool),
bindMap: make(map[string]*NiriKeyBinding),
bindOrder: []string{},
currentSource: "",
}
}
@@ -101,6 +105,7 @@ func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, erro
Name: sectionName,
}
p.currentSource = absPath
baseDir := filepath.Dir(absPath)
p.processNodes(doc.Nodes, section, baseDir)
@@ -127,14 +132,14 @@ func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, ba
return
}
includePath := node.Arguments[0].String()
includePath = strings.Trim(includePath, "\"")
includePath := strings.Trim(node.Arguments[0].String(), "\"")
if includePath == "dms/binds.kdl" || strings.HasSuffix(includePath, "/dms/binds.kdl") {
p.dmsBindsIncluded = true
}
var fullPath string
fullPath := filepath.Join(baseDir, includePath)
if filepath.IsAbs(includePath) {
fullPath = includePath
} else {
fullPath = filepath.Join(baseDir, includePath)
}
includedSection, err := p.parseFile(fullPath, "")
@@ -145,6 +150,10 @@ func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, ba
section.Children = append(section.Children, includedSection.Children...)
}
func (p *NiriParser) HasDMSBindsIncluded() bool {
return p.dmsBindsIncluded
}
func (p *NiriParser) handleRecentWindows(node *document.Node, section *NiriSection) {
if node.Children == nil {
return
@@ -172,7 +181,7 @@ func (p *NiriParser) extractBinds(node *document.Node, section *NiriSection, sub
}
}
func (p *NiriParser) parseKeybindNode(node *document.Node, subcategory string) *NiriKeyBinding {
func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBinding {
keyCombo := node.Name.String()
if keyCombo == "" {
return nil
@@ -182,19 +191,18 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, subcategory string) *
var action string
var args []string
if len(node.Children) > 0 {
actionNode := node.Children[0]
action = actionNode.Name.String()
for _, arg := range actionNode.Arguments {
args = append(args, strings.Trim(arg.String(), "\""))
args = append(args, arg.ValueString())
}
}
description := ""
var description string
if node.Properties != nil {
if val, ok := node.Properties.Get("hotkey-overlay-title"); ok {
description = strings.Trim(val.String(), "\"")
description = val.ValueString()
}
}
@@ -204,26 +212,36 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, subcategory string) *
Action: action,
Args: args,
Description: description,
Source: p.currentSource,
}
}
func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) {
parts := strings.Split(combo, "+")
if len(parts) == 0 {
switch len(parts) {
case 0:
return nil, combo
}
if len(parts) == 1 {
case 1:
return nil, parts[0]
default:
return parts[:len(parts)-1], parts[len(parts)-1]
}
mods := parts[:len(parts)-1]
key := parts[len(parts)-1]
return mods, key
}
func ParseNiriKeys(configDir string) (*NiriSection, error) {
type NiriParseResult struct {
Section *NiriSection
DMSBindsIncluded bool
}
func ParseNiriKeys(configDir string) (*NiriParseResult, error) {
parser := NewNiriParser(configDir)
return parser.Parse()
section, err := parser.Parse()
if err != nil {
return nil, err
}
return &NiriParseResult{
Section: section,
DMSBindsIncluded: parser.HasDMSBindsIncluded(),
}, nil
}

View File

@@ -57,20 +57,20 @@ func TestNiriParseBasicBinds(t *testing.T) {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 3 {
t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 3 {
t.Errorf("Expected 3 keybinds, got %d", len(result.Section.Keybinds))
}
foundClose := false
foundFullscreen := false
foundTerminal := false
for _, kb := range section.Keybinds {
for _, kb := range result.Section.Keybinds {
switch kb.Action {
case "close-window":
foundClose = true
@@ -116,19 +116,19 @@ func TestNiriParseRecentWindows(t *testing.T) {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds from recent-windows, got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds from recent-windows, got %d", len(result.Section.Keybinds))
}
foundNext := false
foundPrev := false
for _, kb := range section.Keybinds {
for _, kb := range result.Section.Keybinds {
switch kb.Action {
case "next-window":
foundNext = true
@@ -172,13 +172,13 @@ include "dms/binds.kdl"
t.Fatalf("Failed to write include config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds (1 main + 1 include), got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds (1 main + 1 include), got %d", len(result.Section.Keybinds))
}
}
@@ -209,17 +209,17 @@ include "dms/binds.kdl"
t.Fatalf("Failed to write include config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 1 {
t.Errorf("Expected 1 keybind (later overrides earlier), got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 1 {
t.Errorf("Expected 1 keybind (later overrides earlier), got %d", len(result.Section.Keybinds))
}
if len(section.Keybinds) > 0 {
kb := section.Keybinds[0]
if len(result.Section.Keybinds) > 0 {
kb := result.Section.Keybinds[0]
if kb.Description != "Override Terminal" {
t.Errorf("Expected description 'Override Terminal' (from include), got %q", kb.Description)
}
@@ -253,13 +253,13 @@ include "config.kdl"
t.Fatalf("Failed to write other config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed (should handle circular includes): %v", err)
}
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds (circular include handled), got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds (circular include handled), got %d", len(result.Section.Keybinds))
}
}
@@ -276,13 +276,13 @@ include "nonexistent/file.kdl"
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed (should skip missing include): %v", err)
}
if len(section.Keybinds) != 1 {
t.Errorf("Expected 1 keybind (missing include skipped), got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 1 {
t.Errorf("Expected 1 keybind (missing include skipped), got %d", len(result.Section.Keybinds))
}
}
@@ -305,13 +305,13 @@ input {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 0 {
t.Errorf("Expected 0 keybinds, got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 0 {
t.Errorf("Expected 0 keybinds, got %d", len(result.Section.Keybinds))
}
}
@@ -352,18 +352,18 @@ func TestNiriBindOverrideBehavior(t *testing.T) {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 3 {
t.Fatalf("Expected 3 unique keybinds, got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 3 {
t.Fatalf("Expected 3 unique keybinds, got %d", len(result.Section.Keybinds))
}
var modT *NiriKeyBinding
for i := range section.Keybinds {
kb := &section.Keybinds[i]
for i := range result.Section.Keybinds {
kb := &result.Section.Keybinds[i]
if len(kb.Mods) == 1 && kb.Mods[0] == "Mod" && kb.Key == "T" {
modT = kb
break
@@ -416,18 +416,18 @@ binds {
t.Fatalf("Failed to write include config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 4 {
t.Errorf("Expected 4 unique keybinds, got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 4 {
t.Errorf("Expected 4 unique keybinds, got %d", len(result.Section.Keybinds))
}
bindMap := make(map[string]*NiriKeyBinding)
for i := range section.Keybinds {
kb := &section.Keybinds[i]
for i := range result.Section.Keybinds {
kb := &result.Section.Keybinds[i]
key := ""
for _, m := range kb.Mods {
key += m + "+"
@@ -475,16 +475,16 @@ func TestNiriParseMultipleArgs(t *testing.T) {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseNiriKeys(tmpDir)
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(section.Keybinds) != 1 {
t.Fatalf("Expected 1 keybind, got %d", len(section.Keybinds))
if len(result.Section.Keybinds) != 1 {
t.Fatalf("Expected 1 keybind, got %d", len(result.Section.Keybinds))
}
kb := section.Keybinds[0]
kb := result.Section.Keybinds[0]
if len(kb.Args) != 5 {
t.Errorf("Expected 5 args, got %d: %v", len(kb.Args), kb.Args)
}

View File

@@ -186,6 +186,144 @@ func TestNiriDefaultConfigDir(t *testing.T) {
}
}
func TestNiriGenerateBindsContent(t *testing.T) {
provider := NewNiriProvider("")
tests := []struct {
name string
binds map[string]*overrideBind
expected string
}{
{
name: "empty binds",
binds: map[string]*overrideBind{},
expected: "binds {}\n",
},
{
name: "simple spawn bind",
binds: map[string]*overrideBind{
"Mod+T": {
Key: "Mod+T",
Action: "spawn kitty",
Description: "Open Terminal",
},
},
expected: `binds {
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
}
`,
},
{
name: "spawn with multiple args",
binds: map[string]*overrideBind{
"Mod+Space": {
Key: "Mod+Space",
Action: `spawn "dms" "ipc" "call" "spotlight" "toggle"`,
Description: "Application Launcher",
},
},
expected: `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
}
`,
},
{
name: "bind with allow-when-locked",
binds: map[string]*overrideBind{
"XF86AudioMute": {
Key: "XF86AudioMute",
Action: `spawn "dms" "ipc" "call" "audio" "mute"`,
Options: map[string]any{"allow-when-locked": true},
},
},
expected: `binds {
XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; }
}
`,
},
{
name: "simple action without args",
binds: map[string]*overrideBind{
"Mod+Q": {
Key: "Mod+Q",
Action: "close-window",
Description: "Close Window",
},
},
expected: `binds {
Mod+Q hotkey-overlay-title="Close Window" { close-window; }
}
`,
},
{
name: "recent-windows action",
binds: map[string]*overrideBind{
"Alt+Tab": {
Key: "Alt+Tab",
Action: "next-window",
},
},
expected: `binds {
}
recent-windows {
binds {
Alt+Tab { next-window; }
}
}
`,
},
}
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 TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
provider := NewNiriProvider("")
binds := map[string]*overrideBind{
"Mod+Space": {
Key: "Mod+Space",
Action: `spawn "dms" "ipc" "call" "spotlight" "toggle"`,
Description: "Application Launcher",
},
"XF86AudioMute": {
Key: "XF86AudioMute",
Action: `spawn "dms" "ipc" "call" "audio" "mute"`,
Options: map[string]any{"allow-when-locked": true},
},
"Mod+Q": {
Key: "Mod+Q",
Action: "close-window",
Description: "Close Window",
},
}
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) != 3 {
t.Errorf("Expected 3 keybinds after round-trip, got %d", len(result.Section.Keybinds))
}
}
func TestNiriProviderWithRealWorldConfig(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")

View File

@@ -5,15 +5,24 @@ type Keybind struct {
Description string `json:"desc"`
Action string `json:"action,omitempty"`
Subcategory string `json:"subcat,omitempty"`
Source string `json:"source,omitempty"`
}
type CheatSheet struct {
Title string `json:"title"`
Provider string `json:"provider"`
Binds map[string][]Keybind `json:"binds"`
Title string `json:"title"`
Provider string `json:"provider"`
Binds map[string][]Keybind `json:"binds"`
DMSBindsIncluded bool `json:"dmsBindsIncluded"`
}
type Provider interface {
Name() string
GetCheatSheet() (*CheatSheet, error)
}
type WritableProvider interface {
Provider
SetBind(key, action, description string, options map[string]any) error
RemoveBind(key string) error
GetOverridePath() string
}