mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-25 05:52:50 -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:
@@ -40,11 +40,34 @@ var keybindsShowCmd = &cobra.Command{
|
||||
Run: runKeybindsShow,
|
||||
}
|
||||
|
||||
var keybindsSetCmd = &cobra.Command{
|
||||
Use: "set <provider> <key> <action>",
|
||||
Short: "Set a keybind override",
|
||||
Long: "Create or update a keybind override for the specified provider",
|
||||
Args: cobra.ExactArgs(3),
|
||||
Run: runKeybindsSet,
|
||||
}
|
||||
|
||||
var keybindsRemoveCmd = &cobra.Command{
|
||||
Use: "remove <provider> <key>",
|
||||
Short: "Remove a keybind override",
|
||||
Long: "Remove a keybind override from the specified provider",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runKeybindsRemove,
|
||||
}
|
||||
|
||||
func init() {
|
||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
|
||||
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
|
||||
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
|
||||
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
||||
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
|
||||
|
||||
keybindsCmd.AddCommand(keybindsListCmd)
|
||||
keybindsCmd.AddCommand(keybindsShowCmd)
|
||||
keybindsCmd.AddCommand(keybindsSetCmd)
|
||||
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
||||
|
||||
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
||||
return providers.NewJSONFileProvider(filePath)
|
||||
@@ -82,69 +105,122 @@ func initializeProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
func runKeybindsList(cmd *cobra.Command, args []string) {
|
||||
registry := keybinds.GetDefaultRegistry()
|
||||
providers := registry.List()
|
||||
|
||||
if len(providers) == 0 {
|
||||
func runKeybindsList(_ *cobra.Command, _ []string) {
|
||||
providerList := keybinds.GetDefaultRegistry().List()
|
||||
if len(providerList) == 0 {
|
||||
fmt.Fprintln(os.Stdout, "No providers available")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Available providers:")
|
||||
for _, name := range providers {
|
||||
for _, name := range providerList {
|
||||
fmt.Fprintf(os.Stdout, " - %s\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
func runKeybindsShow(cmd *cobra.Command, args []string) {
|
||||
providerName := args[0]
|
||||
registry := keybinds.GetDefaultRegistry()
|
||||
|
||||
customPath, _ := cmd.Flags().GetString("path")
|
||||
if customPath != "" {
|
||||
var provider keybinds.Provider
|
||||
switch providerName {
|
||||
case "hyprland":
|
||||
provider = providers.NewHyprlandProvider(customPath)
|
||||
case "mangowc":
|
||||
provider = providers.NewMangoWCProvider(customPath)
|
||||
case "sway":
|
||||
provider = providers.NewSwayProvider(customPath)
|
||||
case "niri":
|
||||
provider = providers.NewNiriProvider(customPath)
|
||||
default:
|
||||
log.Fatalf("Provider %s does not support custom path", providerName)
|
||||
}
|
||||
|
||||
sheet, err := provider.GetCheatSheet()
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
output, err := json.MarshalIndent(sheet, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error generating JSON: %v", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := registry.Get(providerName)
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
func makeProviderWithPath(name, path string) keybinds.Provider {
|
||||
switch name {
|
||||
case "hyprland":
|
||||
return providers.NewHyprlandProvider(path)
|
||||
case "mangowc":
|
||||
return providers.NewMangoWCProvider(path)
|
||||
case "sway":
|
||||
return providers.NewSwayProvider(path)
|
||||
case "niri":
|
||||
return providers.NewNiriProvider(path)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func printCheatSheet(provider keybinds.Provider) {
|
||||
sheet, err := provider.GetCheatSheet()
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
output, err := json.MarshalIndent(sheet, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error generating JSON: %v", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func runKeybindsShow(cmd *cobra.Command, args []string) {
|
||||
providerName := args[0]
|
||||
customPath, _ := cmd.Flags().GetString("path")
|
||||
|
||||
if customPath != "" {
|
||||
provider := makeProviderWithPath(providerName, customPath)
|
||||
if provider == nil {
|
||||
log.Fatalf("Provider %s does not support custom path", providerName)
|
||||
}
|
||||
printCheatSheet(provider)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := keybinds.GetDefaultRegistry().Get(providerName)
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
printCheatSheet(provider)
|
||||
}
|
||||
|
||||
func getWritableProvider(name string) keybinds.WritableProvider {
|
||||
provider, err := keybinds.GetDefaultRegistry().Get(name)
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
writable, ok := provider.(keybinds.WritableProvider)
|
||||
if !ok {
|
||||
log.Fatalf("Provider %s does not support writing keybinds", name)
|
||||
}
|
||||
return writable
|
||||
}
|
||||
|
||||
func runKeybindsSet(cmd *cobra.Command, args []string) {
|
||||
providerName, key, action := args[0], args[1], args[2]
|
||||
writable := getWritableProvider(providerName)
|
||||
|
||||
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
|
||||
_ = writable.RemoveBind(replaceKey)
|
||||
}
|
||||
|
||||
options := make(map[string]any)
|
||||
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
|
||||
options["allow-when-locked"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
|
||||
options["cooldown-ms"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
|
||||
desc, _ := cmd.Flags().GetString("desc")
|
||||
if err := writable.SetBind(key, action, desc, options); err != nil {
|
||||
log.Fatalf("Error setting keybind: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(map[string]any{
|
||||
"success": true,
|
||||
"key": key,
|
||||
"action": action,
|
||||
"path": writable.GetOverridePath(),
|
||||
}, "", " ")
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func runKeybindsRemove(_ *cobra.Command, args []string) {
|
||||
providerName, key := args[0], args[1]
|
||||
writable := getWritableProvider(providerName)
|
||||
|
||||
if err := writable.RemoveBind(key); err != nil {
|
||||
log.Fatalf("Error removing keybind: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(map[string]any{
|
||||
"success": true,
|
||||
"key": key,
|
||||
"removed": true,
|
||||
}, "", " ")
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := §ion.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 := §ion.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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user