diff options
| author | nippy <you@example.com> | 2026-04-18 13:49:56 +0200 |
|---|---|---|
| committer | nippy <you@example.com> | 2026-04-18 13:49:56 +0200 |
| commit | 5d94c0a7d44a2255b81815a52a7056a94a39842d (patch) | |
| tree | 759bdea9645c6a62f9e1e4c001f7d81cccd120d2 /raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds | |
| parent | e79cdf210b267f21a186255ce1a4d50029439d54 (diff) | |
| download | RaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.tar.gz RaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.zip | |
update Raveos themes
Diffstat (limited to 'raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds')
25 files changed, 8874 insertions, 0 deletions
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go new file mode 100644 index 0000000..4990975 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go @@ -0,0 +1,107 @@ +package keybinds + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type DiscoveryConfig struct { + SearchPaths []string +} + +func DefaultDiscoveryConfig() *DiscoveryConfig { + var searchPaths []string + + configDir, err := os.UserConfigDir() + if err == nil && configDir != "" { + searchPaths = append(searchPaths, filepath.Join(configDir, "DankMaterialShell", "cheatsheets")) + } + + configDirs := os.Getenv("XDG_CONFIG_DIRS") + if configDirs != "" { + for dir := range strings.SplitSeq(configDirs, ":") { + if dir != "" { + searchPaths = append(searchPaths, filepath.Join(dir, "DankMaterialShell", "cheatsheets")) + } + } + } + + return &DiscoveryConfig{ + SearchPaths: searchPaths, + } +} + +func (d *DiscoveryConfig) FindJSONFiles() ([]string, error) { + var files []string + + for _, searchPath := range d.SearchPaths { + expandedPath, err := utils.ExpandPath(searchPath) + if err != nil { + continue + } + + if _, err := os.Stat(expandedPath); os.IsNotExist(err) { + continue + } + + entries, err := os.ReadDir(expandedPath) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + fullPath := filepath.Join(expandedPath, entry.Name()) + files = append(files, fullPath) + } + } + + return files, nil +} + +type JSONProviderFactory func(filePath string) (Provider, error) + +var jsonProviderFactory JSONProviderFactory + +func SetJSONProviderFactory(factory JSONProviderFactory) { + jsonProviderFactory = factory +} + +func AutoDiscoverProviders(registry *Registry, config *DiscoveryConfig) error { + if config == nil { + config = DefaultDiscoveryConfig() + } + + if jsonProviderFactory == nil { + return nil + } + + files, err := config.FindJSONFiles() + if err != nil { + return fmt.Errorf("failed to discover JSON files: %w", err) + } + + for _, file := range files { + provider, err := jsonProviderFactory(file) + if err != nil { + continue + } + + if err := registry.Register(provider); err != nil { + continue + } + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go new file mode 100644 index 0000000..a78724e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go @@ -0,0 +1,287 @@ +package keybinds + +import ( + "os" + "path/filepath" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestDefaultDiscoveryConfig(t *testing.T) { + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") + oldConfigDirs := os.Getenv("XDG_CONFIG_DIRS") + defer func() { + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) + os.Setenv("XDG_CONFIG_DIRS", oldConfigDirs) + }() + + tests := []struct { + name string + configHome string + configDirs string + expectedCount int + checkFirstPath bool + firstPath string + }{ + { + name: "default with no XDG vars", + configHome: "", + configDirs: "", + expectedCount: 1, + checkFirstPath: true, + }, + { + name: "with XDG_CONFIG_HOME set", + configHome: "/custom/config", + configDirs: "", + expectedCount: 1, + checkFirstPath: true, + firstPath: "/custom/config/DankMaterialShell/cheatsheets", + }, + { + name: "with XDG_CONFIG_DIRS set", + configHome: "/home/user/.config", + configDirs: "/etc/xdg:/opt/config", + expectedCount: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("XDG_CONFIG_HOME", tt.configHome) + os.Setenv("XDG_CONFIG_DIRS", tt.configDirs) + + config := DefaultDiscoveryConfig() + + if config == nil { + t.Fatal("DefaultDiscoveryConfig returned nil") + } + + if len(config.SearchPaths) != tt.expectedCount { + t.Errorf("SearchPaths count = %d, want %d", len(config.SearchPaths), tt.expectedCount) + } + + if tt.checkFirstPath && len(config.SearchPaths) > 0 { + if tt.firstPath != "" && config.SearchPaths[0] != tt.firstPath { + t.Errorf("SearchPaths[0] = %q, want %q", config.SearchPaths[0], tt.firstPath) + } + } + }) + } +} + +func TestFindJSONFiles(t *testing.T) { + tmpDir := t.TempDir() + + file1 := filepath.Join(tmpDir, "tmux.json") + file2 := filepath.Join(tmpDir, "vim.json") + txtFile := filepath.Join(tmpDir, "readme.txt") + subdir := filepath.Join(tmpDir, "subdir") + + if err := os.WriteFile(file1, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file1: %v", err) + } + if err := os.WriteFile(file2, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file2: %v", err) + } + if err := os.WriteFile(txtFile, []byte("text"), 0o644); err != nil { + t.Fatalf("Failed to create txt file: %v", err) + } + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir}, + } + + files, err := config.FindJSONFiles() + if err != nil { + t.Fatalf("FindJSONFiles failed: %v", err) + } + + if len(files) != 2 { + t.Errorf("expected 2 JSON files, got %d", len(files)) + } + + found := make(map[string]bool) + for _, f := range files { + found[filepath.Base(f)] = true + } + + if !found["tmux.json"] { + t.Error("tmux.json not found") + } + if !found["vim.json"] { + t.Error("vim.json not found") + } + if found["readme.txt"] { + t.Error("readme.txt should not be included") + } +} + +func TestFindJSONFilesNonexistentPath(t *testing.T) { + config := &DiscoveryConfig{ + SearchPaths: []string{"/nonexistent/path"}, + } + + files, err := config.FindJSONFiles() + if err != nil { + t.Fatalf("FindJSONFiles failed: %v", err) + } + + if len(files) != 0 { + t.Errorf("expected 0 files for nonexistent path, got %d", len(files)) + } +} + +func TestFindJSONFilesMultiplePaths(t *testing.T) { + tmpDir1 := t.TempDir() + tmpDir2 := t.TempDir() + + file1 := filepath.Join(tmpDir1, "app1.json") + file2 := filepath.Join(tmpDir2, "app2.json") + + if err := os.WriteFile(file1, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file1: %v", err) + } + if err := os.WriteFile(file2, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file2: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir1, tmpDir2}, + } + + files, err := config.FindJSONFiles() + if err != nil { + t.Fatalf("FindJSONFiles failed: %v", err) + } + + if len(files) != 2 { + t.Errorf("expected 2 JSON files from multiple paths, got %d", len(files)) + } +} + +func TestAutoDiscoverProviders(t *testing.T) { + tmpDir := t.TempDir() + + jsonContent := `{ + "title": "Test App", + "provider": "testapp", + "binds": {} +}` + + file := filepath.Join(tmpDir, "testapp.json") + if err := os.WriteFile(file, []byte(jsonContent), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir}, + } + + registry := NewRegistry() + + factoryCalled := false + SetJSONProviderFactory(func(filePath string) (Provider, error) { + factoryCalled = true + return &mockProvider{name: "testapp"}, nil + }) + + err := AutoDiscoverProviders(registry, config) + if err != nil { + t.Fatalf("AutoDiscoverProviders failed: %v", err) + } + + if !factoryCalled { + t.Error("factory was not called") + } + + provider, err := registry.Get("testapp") + if err != nil { + t.Fatalf("provider not registered: %v", err) + } + + if provider.Name() != "testapp" { + t.Errorf("provider name = %q, want %q", provider.Name(), "testapp") + } +} + +func TestAutoDiscoverProvidersNilConfig(t *testing.T) { + registry := NewRegistry() + + SetJSONProviderFactory(func(filePath string) (Provider, error) { + return &mockProvider{name: "test"}, nil + }) + + err := AutoDiscoverProviders(registry, nil) + if err != nil { + t.Fatalf("AutoDiscoverProviders with nil config failed: %v", err) + } +} + +func TestAutoDiscoverProvidersNoFactory(t *testing.T) { + tmpDir := t.TempDir() + + file := filepath.Join(tmpDir, "test.json") + if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir}, + } + + registry := NewRegistry() + + SetJSONProviderFactory(nil) + + err := AutoDiscoverProviders(registry, config) + if err != nil { + t.Fatalf("AutoDiscoverProviders should not fail without factory: %v", err) + } + + providers := registry.List() + if len(providers) != 0 { + t.Errorf("expected 0 providers without factory, got %d", len(providers)) + } +} + +func TestExpandPathInDiscovery(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "tilde expansion", + input: "~/test", + expected: filepath.Join(home, "test"), + }, + { + name: "absolute path", + input: "/tmp/test", + expected: "/tmp/test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := utils.ExpandPath(tt.input) + if err != nil { + t.Fatalf("expandPath failed: %v", err) + } + + if result != tt.expected { + t.Errorf("utils.ExpandPath(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go new file mode 100644 index 0000000..e43a9eb --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go @@ -0,0 +1,497 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type HyprlandProvider struct { + configPath string + dmsBindsIncluded bool + parsed bool +} + +func NewHyprlandProvider(configPath string) *HyprlandProvider { + if configPath == "" { + configPath = defaultHyprlandConfigDir() + } + return &HyprlandProvider{ + configPath: configPath, + } +} + +func defaultHyprlandConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "hypr") +} + +func (h *HyprlandProvider) Name() string { + return "hyprland" +} + +func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + result, err := ParseHyprlandKeysWithDMS(h.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse hyprland config: %w", err) + } + + h.dmsBindsIncluded = result.DMSBindsIncluded + h.parsed = true + + categorizedBinds := make(map[string][]keybinds.Keybind) + h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs) + + sheet := &keybinds.CheatSheet{ + Title: "Hyprland Keybinds", + Provider: h.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, nil +} + +func (h *HyprlandProvider) HasDMSBindsIncluded() bool { + if h.parsed { + return h.dmsBindsIncluded + } + + result, err := ParseHyprlandKeysWithDMS(h.configPath) + if err != nil { + return false + } + + h.dmsBindsIncluded = result.DMSBindsIncluded + h.parsed = true + return h.dmsBindsIncluded +} + +func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) { + currentSubcat := subcategory + if section.Name != "" { + currentSubcat = section.Name + } + + for _, kb := range section.Keybinds { + category := h.categorizeByDispatcher(kb.Dispatcher) + bind := h.convertKeybind(&kb, currentSubcat, conflicts) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + for _, child := range section.Children { + h.convertSection(&child, currentSubcat, categorizedBinds, conflicts) + } +} + +func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { + switch { + case strings.Contains(dispatcher, "workspace"): + return "Workspace" + case strings.Contains(dispatcher, "monitor"): + return "Monitor" + case strings.Contains(dispatcher, "window") || + strings.Contains(dispatcher, "focus") || + strings.Contains(dispatcher, "move") || + strings.Contains(dispatcher, "swap") || + strings.Contains(dispatcher, "resize") || + dispatcher == "killactive" || + dispatcher == "fullscreen" || + dispatcher == "togglefloating" || + dispatcher == "pin" || + dispatcher == "fakefullscreen" || + dispatcher == "splitratio" || + dispatcher == "resizeactive": + return "Window" + case dispatcher == "exec": + return "Execute" + case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"): + return "System" + default: + return "Other" + } +} + +func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind { + keyStr := h.formatKey(kb) + rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) + desc := kb.Comment + + if desc == "" { + desc = rawAction + } + + source := "config" + if strings.Contains(kb.Source, "dms/binds.conf") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, + Description: desc, + Action: rawAction, + Subcategory: subcategory, + Source: source, + Flags: kb.Flags, + } + + if source == "dms" && conflicts != nil { + normalizedKey := strings.ToLower(keyStr) + if conflictKb, ok := conflicts[normalizedKey]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Comment, + Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params), + Source: "config", + } + } + } + + return bind +} + +func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { + if params != "" { + return dispatcher + " " + params + } + return dispatcher +} + +func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (h *HyprlandProvider) GetOverridePath() string { + expanded, err := utils.ExpandPath(h.configPath) + if err != nil { + return filepath.Join(h.configPath, "dms", "binds.conf") + } + return filepath.Join(expanded, "dms", "binds.conf") +} + +func (h *HyprlandProvider) validateAction(action string) error { + action = strings.TrimSpace(action) + switch { + case action == "": + return fmt.Errorf("action cannot be empty") + case action == "exec" || action == "exec ": + return fmt.Errorf("exec dispatcher requires arguments") + case strings.HasPrefix(action, "exec "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "exec ")) + if rest == "" { + return fmt.Errorf("exec dispatcher requires arguments") + } + } + return nil +} + +func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { + if err := h.validateAction(action); err != nil { + return err + } + + overridePath := h.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + existingBinds, err := h.loadOverrideBinds() + if err != nil { + existingBinds = make(map[string]*hyprlandOverrideBind) + } + + // Extract flags from options + var flags string + if options != nil { + if f, ok := options["flags"].(string); ok { + flags = f + } + } + + normalizedKey := strings.ToLower(key) + existingBinds[normalizedKey] = &hyprlandOverrideBind{ + Key: key, + Action: action, + Description: description, + Flags: flags, + Options: options, + } + + return h.writeOverrideBinds(existingBinds) +} + +func (h *HyprlandProvider) RemoveBind(key string) error { + existingBinds, err := h.loadOverrideBinds() + if err != nil { + return nil + } + + normalizedKey := strings.ToLower(key) + delete(existingBinds, normalizedKey) + return h.writeOverrideBinds(existingBinds) +} + +type hyprlandOverrideBind struct { + Key string + Action string + Description string + Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press + Options map[string]any +} + +func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { + overridePath := h.GetOverridePath() + binds := make(map[string]*hyprlandOverrideBind) + + data, err := os.ReadFile(overridePath) + if os.IsNotExist(err) { + return binds, nil + } + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if !strings.HasPrefix(line, "bind") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + + // Extract flags from bind type + bindType := strings.TrimSpace(parts[0]) + flags := extractBindFlags(bindType) + hasDescFlag := strings.Contains(flags, "d") + + content := strings.TrimSpace(parts[1]) + commentParts := strings.SplitN(content, "#", 2) + bindContent := strings.TrimSpace(commentParts[0]) + + var comment string + if len(commentParts) > 1 { + comment = strings.TrimSpace(commentParts[1]) + } + + // For bindd, format is: mods, key, description, dispatcher, params + var minFields, descIndex, dispatcherIndex int + if hasDescFlag { + minFields = 4 + descIndex = 2 + dispatcherIndex = 3 + } else { + minFields = 3 + dispatcherIndex = 2 + } + + fields := strings.SplitN(bindContent, ",", minFields+2) + if len(fields) < minFields { + continue + } + + mods := strings.TrimSpace(fields[0]) + keyName := strings.TrimSpace(fields[1]) + + var dispatcher, params string + if hasDescFlag { + if comment == "" { + comment = strings.TrimSpace(fields[descIndex]) + } + dispatcher = strings.TrimSpace(fields[dispatcherIndex]) + if len(fields) > dispatcherIndex+1 { + paramParts := fields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } else { + dispatcher = strings.TrimSpace(fields[dispatcherIndex]) + if len(fields) > dispatcherIndex+1 { + paramParts := fields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } + + keyStr := h.buildKeyString(mods, keyName) + normalizedKey := strings.ToLower(keyStr) + action := dispatcher + if params != "" { + action = dispatcher + " " + params + } + + binds[normalizedKey] = &hyprlandOverrideBind{ + Key: keyStr, + Action: action, + Description: comment, + Flags: flags, + } + } + + return binds, nil +} + +func (h *HyprlandProvider) buildKeyString(mods, key string) string { + if mods == "" { + return key + } + + modList := strings.FieldsFunc(mods, func(r rune) bool { + return r == '+' || r == ' ' + }) + + parts := append(modList, key) + return strings.Join(parts, "+") +} + +func (h *HyprlandProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "workspace"): + return 1 + case strings.Contains(action, "window") || strings.Contains(action, "focus") || + strings.Contains(action, "move") || strings.Contains(action, "swap") || + strings.Contains(action, "resize"): + return 2 + case strings.Contains(action, "monitor"): + return 3 + case strings.HasPrefix(action, "exec"): + return 4 + case action == "exit" || strings.Contains(action, "dpms"): + return 5 + default: + return 6 + } +} + +func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error { + overridePath := h.GetOverridePath() + content := h.generateBindsContent(binds) + return os.WriteFile(overridePath, []byte(content), 0o644) +} + +func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string { + if len(binds) == 0 { + return "" + } + + bindList := make([]*hyprlandOverrideBind, 0, len(binds)) + for _, bind := range binds { + bindList = append(bindList, bind) + } + + sort.Slice(bindList, func(i, j int) bool { + pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action) + if pi != pj { + return pi < pj + } + return bindList[i].Key < bindList[j].Key + }) + + var sb strings.Builder + for _, bind := range bindList { + h.writeBindLine(&sb, bind) + } + + return sb.String() +} + +func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { + mods, key := h.parseKeyString(bind.Key) + dispatcher, params := h.parseAction(bind.Action) + + // Write bind type with flags (e.g., "bind", "binde", "bindel") + sb.WriteString("bind") + if bind.Flags != "" { + sb.WriteString(bind.Flags) + } + sb.WriteString(" = ") + sb.WriteString(mods) + sb.WriteString(", ") + sb.WriteString(key) + sb.WriteString(", ") + + // For bindd (description flag), include description before dispatcher + if strings.Contains(bind.Flags, "d") && bind.Description != "" { + sb.WriteString(bind.Description) + sb.WriteString(", ") + } + + sb.WriteString(dispatcher) + + if params != "" { + sb.WriteString(", ") + sb.WriteString(params) + } + + // Only add comment if not using bindd (which has inline description) + if bind.Description != "" && !strings.Contains(bind.Flags, "d") { + sb.WriteString(" # ") + sb.WriteString(bind.Description) + } + + sb.WriteString("\n") +} + +func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) { + parts := strings.Split(keyStr, "+") + switch len(parts) { + case 0: + return "", keyStr + case 1: + return "", parts[0] + default: + return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1] + } +} + +func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) { + parts := strings.SplitN(action, " ", 2) + switch len(parts) { + case 0: + return action, "" + case 1: + dispatcher = parts[0] + default: + dispatcher = parts[0] + params = parts[1] + } + + // Convert internal spawn format to Hyprland's exec + if dispatcher == "spawn" { + dispatcher = "exec" + } + + return dispatcher, params +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go new file mode 100644 index 0000000..04b5ada --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go @@ -0,0 +1,627 @@ +package providers + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +const ( + TitleRegex = "#+!" + HideComment = "[hidden]" + CommentBindPattern = "#/#" +) + +var ModSeparators = []rune{'+', ' '} + +type HyprlandKeyBinding struct { + Mods []string `json:"mods"` + Key string `json:"key"` + Dispatcher string `json:"dispatcher"` + Params string `json:"params"` + Comment string `json:"comment"` + Source string `json:"source"` + Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press +} + +type HyprlandSection struct { + Children []HyprlandSection `json:"children"` + Keybinds []HyprlandKeyBinding `json:"keybinds"` + Name string `json:"name"` +} + +type HyprlandParser struct { + contentLines []string + readingLine int + configDir string + currentSource string + dmsBindsExists bool + dmsBindsIncluded bool + includeCount int + dmsIncludePos int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + conflictingConfigs map[string]*HyprlandKeyBinding + bindMap map[string]*HyprlandKeyBinding + bindOrder []string + processedFiles map[string]bool + dmsProcessed bool +} + +func NewHyprlandParser(configDir string) *HyprlandParser { + return &HyprlandParser{ + contentLines: []string{}, + readingLine: 0, + configDir: configDir, + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + conflictingConfigs: make(map[string]*HyprlandKeyBinding), + bindMap: make(map[string]*HyprlandKeyBinding), + bindOrder: []string{}, + processedFiles: make(map[string]bool), + } +} + +func (p *HyprlandParser) ReadContent(directory string) error { + expandedDir, err := utils.ExpandPath(directory) + if err != nil { + return err + } + + info, err := os.Stat(expandedDir) + if err != nil { + return err + } + if !info.IsDir() { + return os.ErrNotExist + } + + confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf")) + if err != nil { + return err + } + if len(confFiles) == 0 { + return os.ErrNotExist + } + + var combinedContent []string + for _, confFile := range confFiles { + if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() { + data, err := os.ReadFile(confFile) + if err == nil { + combinedContent = append(combinedContent, string(data)) + } + } + } + + if len(combinedContent) == 0 { + return os.ErrNotExist + } + + fullContent := strings.Join(combinedContent, "\n") + p.contentLines = strings.Split(fullContent, "\n") + return nil +} + +func hyprlandAutogenerateComment(dispatcher, params string) string { + switch dispatcher { + case "resizewindow": + return "Resize window" + + case "movewindow": + if params == "" { + return "Move window" + } + dirMap := map[string]string{ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + } + if dir, ok := dirMap[params]; ok { + return "move in " + dir + " direction" + } + return "move in null direction" + + case "pin": + return "pin (show on all workspaces)" + + case "splitratio": + return "Window split ratio " + params + + case "togglefloating": + return "Float/unfloat window" + + case "resizeactive": + return "Resize window by " + params + + case "killactive": + return "Close window" + + case "fullscreen": + fsMap := map[string]string{ + "0": "fullscreen", + "1": "maximization", + "2": "fullscreen on Hyprland's side", + } + if fs, ok := fsMap[params]; ok { + return "Toggle " + fs + } + return "Toggle null" + + case "fakefullscreen": + return "Toggle fake fullscreen" + + case "workspace": + switch params { + case "+1": + return "focus right" + case "-1": + return "focus left" + } + return "focus workspace " + params + case "movefocus": + dirMap := map[string]string{ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + } + if dir, ok := dirMap[params]; ok { + return "move focus " + dir + } + return "move focus null" + + case "swapwindow": + dirMap := map[string]string{ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + } + if dir, ok := dirMap[params]; ok { + return "swap in " + dir + " direction" + } + return "swap in null direction" + + case "movetoworkspace": + switch params { + case "+1": + return "move to right workspace (non-silent)" + case "-1": + return "move to left workspace (non-silent)" + } + return "move to workspace " + params + " (non-silent)" + case "movetoworkspacesilent": + switch params { + case "+1": + return "move to right workspace" + case "-1": + return "move to right workspace" + } + return "move to workspace " + params + + case "togglespecialworkspace": + return "toggle special" + + case "exec": + return params + + default: + return "" + } +} + +func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding { + line := p.contentLines[lineNumber] + return p.parseBindLine(line) +} + +func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection { + titleRegex := regexp.MustCompile(TitleRegex) + + for p.readingLine < len(p.contentLines) { + line := p.contentLines[p.readingLine] + + loc := titleRegex.FindStringIndex(line) + if loc != nil && loc[0] == 0 { + headingScope := strings.Index(line, "!") + + if headingScope <= scope { + p.readingLine-- + return currentContent + } + + sectionName := strings.TrimSpace(line[headingScope+1:]) + p.readingLine++ + + childSection := &HyprlandSection{ + Children: []HyprlandSection{}, + Keybinds: []HyprlandKeyBinding{}, + Name: sectionName, + } + result := p.getBindsRecursive(childSection, headingScope) + currentContent.Children = append(currentContent.Children, *result) + + } else if strings.HasPrefix(line, CommentBindPattern) { + keybind := p.getKeybindAtLine(p.readingLine) + if keybind != nil { + currentContent.Keybinds = append(currentContent.Keybinds, *keybind) + } + + } else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") { + + } else { + keybind := p.getKeybindAtLine(p.readingLine) + if keybind != nil { + currentContent.Keybinds = append(currentContent.Keybinds, *keybind) + } + } + + p.readingLine++ + } + + return currentContent +} + +func (p *HyprlandParser) ParseKeys() *HyprlandSection { + p.readingLine = 0 + rootSection := &HyprlandSection{ + Children: []HyprlandSection{}, + Keybinds: []HyprlandKeyBinding{}, + Name: "", + } + return p.getBindsRecursive(rootSection, 0) +} + +func ParseHyprlandKeys(path string) (*HyprlandSection, error) { + parser := NewHyprlandParser(path) + if err := parser.ReadContent(path); err != nil { + return nil, err + } + return parser.ParseKeys(), nil +} + +type HyprlandParseResult struct { + Section *HyprlandSection + DMSBindsIncluded bool + DMSStatus *HyprlandDMSStatus + ConflictingConfigs map[string]*HyprlandKeyBinding +} + +type HyprlandDMSStatus struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { + status := &HyprlandDMSStatus{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.conf does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.conf is not sourced in config" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *HyprlandParser) normalizeKey(key string) string { + return strings.ToLower(key) +} + +func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool { + key := p.formatBindKey(kb) + normalizedKey := p.normalizeKey(key) + isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") + + if isDMSBind { + p.dmsBindKeys[normalizedKey] = true + } else if p.dmsBindKeys[normalizedKey] { + p.bindsAfterDMS++ + p.conflictingConfigs[normalizedKey] = kb + p.configBindKeys[normalizedKey] = true + return false + } else { + p.configBindKeys[normalizedKey] = true + } + + if _, exists := p.bindMap[normalizedKey]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[normalizedKey] = kb + return true +} + +func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) { + expandedDir, err := utils.ExpandPath(p.configDir) + if err != nil { + return nil, err + } + + dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + mainConfig := filepath.Join(expandedDir, "hyprland.conf") + section, err := p.parseFileWithSource(mainConfig, "") + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath, section) + } + + return section, nil +} + +func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + if p.processedFiles[absPath] { + return &HyprlandSection{Name: sectionName}, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + prevSource := p.currentSource + p.currentSource = absPath + + section := &HyprlandSection{Name: sectionName} + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "source") { + p.handleSource(trimmed, section, filepath.Dir(absPath)) + continue + } + + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.parseBindLine(line) + if kb == nil { + continue + } + kb.Source = p.currentSource + if p.addBind(kb) { + section.Keybinds = append(section.Keybinds, *kb) + } + } + + p.currentSource = prevSource + return section, nil +} + +func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return + } + + sourcePath := strings.TrimSpace(parts[1]) + isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") + + p.includeCount++ + if isDMSSource { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + return + } + + includedSection, err := p.parseFileWithSource(expanded, "") + if err != nil { + return + } + + section.Children = append(section.Children, *includedSection) +} + +func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) { + data, err := os.ReadFile(dmsBindsPath) + if err != nil { + return + } + + prevSource := p.currentSource + p.currentSource = dmsBindsPath + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.parseBindLine(line) + if kb == nil { + continue + } + kb.Source = dmsBindsPath + if p.addBind(kb) { + section.Keybinds = append(section.Keybinds, *kb) + } + } + + p.currentSource = prevSource + p.dmsProcessed = true +} + +func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return nil + } + + // Extract bind type and flags from the left side of "=" + bindType := strings.TrimSpace(parts[0]) + flags := extractBindFlags(bindType) + hasDescFlag := strings.Contains(flags, "d") + + keys := parts[1] + keyParts := strings.SplitN(keys, "#", 2) + keys = keyParts[0] + + var comment string + if len(keyParts) > 1 { + comment = strings.TrimSpace(keyParts[1]) + } + + // For bindd, the format is: bindd = MODS, key, description, dispatcher, params + // For regular binds: bind = MODS, key, dispatcher, params + var minFields, descIndex, dispatcherIndex int + if hasDescFlag { + minFields = 4 // mods, key, description, dispatcher + descIndex = 2 + dispatcherIndex = 3 + } else { + minFields = 3 // mods, key, dispatcher + dispatcherIndex = 2 + } + + keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params + if len(keyFields) < minFields { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + + var dispatcher, params string + if hasDescFlag { + // bindd format: description is in the bind itself + if comment == "" { + comment = strings.TrimSpace(keyFields[descIndex]) + } + dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) + if len(keyFields) > dispatcherIndex+1 { + paramParts := keyFields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } else { + dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) + if len(keyFields) > dispatcherIndex+1 { + paramParts := keyFields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } + + if comment != "" && strings.HasPrefix(comment, HideComment) { + return nil + } + + if comment == "" { + comment = hyprlandAutogenerateComment(dispatcher, params) + } + + var modList []string + if mods != "" { + modstring := mods + string(ModSeparators[0]) + idx := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range ModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-idx > 1 { + modList = append(modList, modstring[idx:index]) + } + idx = index + 1 + } + } + } + + return &HyprlandKeyBinding{ + Mods: modList, + Key: key, + Dispatcher: dispatcher, + Params: params, + Comment: comment, + Flags: flags, + } +} + +// extractBindFlags extracts the flags from a bind type string +// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d" +func extractBindFlags(bindType string) string { + bindType = strings.TrimSpace(bindType) + if !strings.HasPrefix(bindType, "bind") { + return "" + } + return bindType[4:] // Everything after "bind" +} + +func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { + parser := NewHyprlandParser(path) + section, err := parser.ParseWithDMS() + if err != nil { + return nil, err + } + + return &HyprlandParseResult{ + Section: section, + DMSBindsIncluded: parser.dmsBindsIncluded, + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go new file mode 100644 index 0000000..c2872e5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go @@ -0,0 +1,519 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHyprlandAutogenerateComment(t *testing.T) { + tests := []struct { + dispatcher string + params string + expected string + }{ + {"resizewindow", "", "Resize window"}, + {"movewindow", "", "Move window"}, + {"movewindow", "l", "move in left direction"}, + {"movewindow", "r", "move in right direction"}, + {"movewindow", "u", "move in up direction"}, + {"movewindow", "d", "move in down direction"}, + {"pin", "", "pin (show on all workspaces)"}, + {"splitratio", "0.5", "Window split ratio 0.5"}, + {"togglefloating", "", "Float/unfloat window"}, + {"resizeactive", "10 20", "Resize window by 10 20"}, + {"killactive", "", "Close window"}, + {"fullscreen", "0", "Toggle fullscreen"}, + {"fullscreen", "1", "Toggle maximization"}, + {"fullscreen", "2", "Toggle fullscreen on Hyprland's side"}, + {"fakefullscreen", "", "Toggle fake fullscreen"}, + {"workspace", "+1", "focus right"}, + {"workspace", "-1", "focus left"}, + {"workspace", "5", "focus workspace 5"}, + {"movefocus", "l", "move focus left"}, + {"movefocus", "r", "move focus right"}, + {"movefocus", "u", "move focus up"}, + {"movefocus", "d", "move focus down"}, + {"swapwindow", "l", "swap in left direction"}, + {"swapwindow", "r", "swap in right direction"}, + {"swapwindow", "u", "swap in up direction"}, + {"swapwindow", "d", "swap in down direction"}, + {"movetoworkspace", "+1", "move to right workspace (non-silent)"}, + {"movetoworkspace", "-1", "move to left workspace (non-silent)"}, + {"movetoworkspace", "3", "move to workspace 3 (non-silent)"}, + {"movetoworkspacesilent", "+1", "move to right workspace"}, + {"movetoworkspacesilent", "-1", "move to right workspace"}, + {"movetoworkspacesilent", "2", "move to workspace 2"}, + {"togglespecialworkspace", "", "toggle special"}, + {"exec", "firefox", "firefox"}, + {"unknown", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.dispatcher+"_"+tt.params, func(t *testing.T) { + result := hyprlandAutogenerateComment(tt.dispatcher, tt.params) + if result != tt.expected { + t.Errorf("hyprlandAutogenerateComment(%q, %q) = %q, want %q", + tt.dispatcher, tt.params, result, tt.expected) + } + }) + } +} + +func TestHyprlandGetKeybindAtLine(t *testing.T) { + tests := []struct { + name string + line string + expected *HyprlandKeyBinding + }{ + { + name: "basic_keybind", + line: "bind = SUPER, Q, killactive", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER"}, + Key: "Q", + Dispatcher: "killactive", + Params: "", + Comment: "Close window", + }, + }, + { + name: "keybind_with_params", + line: "bind = SUPER, left, movefocus, l", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER"}, + Key: "left", + Dispatcher: "movefocus", + Params: "l", + Comment: "move focus left", + }, + }, + { + name: "keybind_with_comment", + line: "bind = SUPER, T, exec, kitty # Open terminal", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER"}, + Key: "T", + Dispatcher: "exec", + Params: "kitty", + Comment: "Open terminal", + }, + }, + { + name: "keybind_hidden", + line: "bind = SUPER, H, exec, secret # [hidden]", + expected: nil, + }, + { + name: "keybind_multiple_mods", + line: "bind = SUPER+SHIFT, F, fullscreen, 0", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER", "SHIFT"}, + Key: "F", + Dispatcher: "fullscreen", + Params: "0", + Comment: "Toggle fullscreen", + }, + }, + { + name: "keybind_no_mods", + line: "bind = , Print, exec, screenshot", + expected: &HyprlandKeyBinding{ + Mods: []string{}, + Key: "Print", + Dispatcher: "exec", + Params: "screenshot", + Comment: "screenshot", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewHyprlandParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if tt.expected == nil { + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + return + } + + if result == nil { + t.Errorf("expected %+v, got nil", tt.expected) + return + } + + if result.Key != tt.expected.Key { + t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key) + } + if result.Dispatcher != tt.expected.Dispatcher { + t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expected.Dispatcher) + } + if result.Params != tt.expected.Params { + t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params) + } + if result.Comment != tt.expected.Comment { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment) + } + if len(result.Mods) != len(tt.expected.Mods) { + t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods)) + } else { + for i := range result.Mods { + if result.Mods[i] != tt.expected.Mods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i]) + } + } + } + }) + } +} + +func TestHyprlandParseKeysWithSections(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + content := `##! Window Management +bind = SUPER, Q, killactive +bind = SUPER, F, fullscreen, 0 + +###! Movement +bind = SUPER, left, movefocus, l +bind = SUPER, right, movefocus, r + +##! Applications +bind = SUPER, T, exec, kitty # Terminal +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseHyprlandKeys(tmpDir) + if err != nil { + t.Fatalf("ParseHyprlandKeys failed: %v", err) + } + + if len(section.Children) != 2 { + t.Errorf("Expected 2 top-level sections, got %d", len(section.Children)) + } + + if len(section.Children) >= 1 { + windowMgmt := section.Children[0] + if windowMgmt.Name != "Window Management" { + t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management") + } + if len(windowMgmt.Keybinds) != 2 { + t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds)) + } + + if len(windowMgmt.Children) != 1 { + t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children)) + } else { + movement := windowMgmt.Children[0] + if movement.Name != "Movement" { + t.Errorf("Movement section name = %q, want %q", movement.Name, "Movement") + } + if len(movement.Keybinds) != 2 { + t.Errorf("Movement keybinds = %d, want 2", len(movement.Keybinds)) + } + } + } + + if len(section.Children) >= 2 { + apps := section.Children[1] + if apps.Name != "Applications" { + t.Errorf("Second section name = %q, want %q", apps.Name, "Applications") + } + if len(apps.Keybinds) != 1 { + t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds)) + } + if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" { + t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal") + } + } +} + +func TestHyprlandParseKeysWithCommentBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "test.conf") + + content := `#/# = SUPER, A, exec, app1 +bind = SUPER, B, exec, app2 +#/# = SUPER, C, exec, app3 # Custom comment +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseHyprlandKeys(tmpDir) + if err != nil { + t.Fatalf("ParseHyprlandKeys failed: %v", err) + } + + if len(section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds)) + } + + if len(section.Keybinds) > 0 && section.Keybinds[0].Key != "A" { + t.Errorf("First keybind key = %q, want %q", section.Keybinds[0].Key, "A") + } + if len(section.Keybinds) > 1 && section.Keybinds[1].Key != "B" { + t.Errorf("Second keybind key = %q, want %q", section.Keybinds[1].Key, "B") + } + if len(section.Keybinds) > 2 && section.Keybinds[2].Comment != "Custom comment" { + t.Errorf("Third keybind comment = %q, want %q", section.Keybinds[2].Comment, "Custom comment") + } +} + +func TestHyprlandReadContentMultipleFiles(t *testing.T) { + tmpDir := t.TempDir() + + file1 := filepath.Join(tmpDir, "a.conf") + file2 := filepath.Join(tmpDir, "b.conf") + + content1 := "bind = SUPER, Q, killactive\n" + content2 := "bind = SUPER, T, exec, kitty\n" + + if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil { + t.Fatalf("Failed to write file1: %v", err) + } + if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil { + t.Fatalf("Failed to write file2: %v", err) + } + + parser := NewHyprlandParser("") + if err := parser.ReadContent(tmpDir); err != nil { + t.Fatalf("ReadContent failed: %v", err) + } + + section := parser.ParseKeys() + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds from multiple files, got %d", len(section.Keybinds)) + } +} + +func TestHyprlandReadContentErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + { + name: "empty_directory", + path: t.TempDir(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseHyprlandKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestHyprlandReadContentWithTildeExpansion(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name()) + if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { + t.Skip("Cannot create test directory in home") + } + defer os.RemoveAll(tmpSubdir) + + configFile := filepath.Join(tmpSubdir, "test.conf") + if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + relPath, err := filepath.Rel(homeDir, tmpSubdir) + if err != nil { + t.Skip("Cannot create relative path") + } + + parser := NewHyprlandParser("") + tildePathMatch := "~/" + relPath + err = parser.ReadContent(tildePathMatch) + + if err != nil { + t.Errorf("ReadContent with tilde path failed: %v", err) + } +} + +func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) { + parser := NewHyprlandParser("") + parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"} + + result := parser.getKeybindAtLine(0) + + if result == nil { + t.Fatal("Expected keybind, got nil") + } + + expected := "notify-send 'Title' 'Message, with comma'" + if result.Params != expected { + t.Errorf("Params = %q, want %q", result.Params, expected) + } +} + +func TestHyprlandEmptyAndCommentLines(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "test.conf") + + content := ` +# This is a comment +bind = SUPER, Q, killactive + +# Another comment + +bind = SUPER, T, exec, kitty +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseHyprlandKeys(tmpDir) + if err != nil { + t.Fatalf("ParseHyprlandKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds)) + } +} + +func TestExtractBindFlags(t *testing.T) { + tests := []struct { + bindType string + expected string + }{ + {"bind", ""}, + {"binde", "e"}, + {"bindl", "l"}, + {"bindr", "r"}, + {"bindd", "d"}, + {"bindo", "o"}, + {"bindel", "el"}, + {"bindler", "ler"}, + {"bindem", "em"}, + {" bind ", ""}, + {" binde ", "e"}, + {"notbind", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.bindType, func(t *testing.T) { + result := extractBindFlags(tt.bindType) + if result != tt.expected { + t.Errorf("extractBindFlags(%q) = %q, want %q", tt.bindType, result, tt.expected) + } + }) + } +} + +func TestHyprlandBindFlags(t *testing.T) { + tests := []struct { + name string + line string + expectedFlags string + expectedKey string + expectedDisp string + expectedDesc string + }{ + { + name: "regular bind", + line: "bind = SUPER, Q, killactive", + expectedFlags: "", + expectedKey: "Q", + expectedDisp: "killactive", + expectedDesc: "Close window", + }, + { + name: "binde (repeat on hold)", + line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+", + expectedFlags: "e", + expectedKey: "XF86AudioRaiseVolume", + expectedDisp: "exec", + expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+", + }, + { + name: "bindl (locked/inhibitor bypass)", + line: "bindl = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", + expectedFlags: "l", + expectedKey: "XF86AudioLowerVolume", + expectedDisp: "exec", + expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", + }, + { + name: "bindr (release trigger)", + line: "bindr = SUPER, SUPER_L, exec, pkill wofi || wofi", + expectedFlags: "r", + expectedKey: "SUPER_L", + expectedDisp: "exec", + expectedDesc: "pkill wofi || wofi", + }, + { + name: "bindd (description)", + line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty", + expectedFlags: "d", + expectedKey: "Q", + expectedDisp: "exec", + expectedDesc: "Open my favourite terminal", + }, + { + name: "bindo (long press)", + line: "bindo = SUPER, XF86AudioNext, exec, playerctl next", + expectedFlags: "o", + expectedKey: "XF86AudioNext", + expectedDisp: "exec", + expectedDesc: "playerctl next", + }, + { + name: "bindel (combined flags)", + line: "bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+", + expectedFlags: "el", + expectedKey: "XF86AudioRaiseVolume", + expectedDisp: "exec", + expectedDesc: "wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewHyprlandParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if result == nil { + t.Fatal("Expected keybind, got nil") + } + + if result.Flags != tt.expectedFlags { + t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags) + } + if result.Key != tt.expectedKey { + t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey) + } + if result.Dispatcher != tt.expectedDisp { + t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp) + } + if result.Comment != tt.expectedDesc { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go new file mode 100644 index 0000000..90dfe97 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go @@ -0,0 +1,220 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewHyprlandProvider(t *testing.T) { + t.Run("custom path", func(t *testing.T) { + p := NewHyprlandProvider("/custom/path") + if p == nil { + t.Fatal("NewHyprlandProvider returned nil") + } + if p.configPath != "/custom/path" { + t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path") + } + }) + + t.Run("empty path defaults", func(t *testing.T) { + p := NewHyprlandProvider("") + if p == nil { + t.Fatal("NewHyprlandProvider returned nil") + } + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatalf("UserConfigDir failed: %v", err) + } + expected := filepath.Join(configDir, "hypr") + if p.configPath != expected { + t.Errorf("configPath = %q, want %q", p.configPath, expected) + } + }) +} + +func TestHyprlandProviderName(t *testing.T) { + p := NewHyprlandProvider("") + if p.Name() != "hyprland" { + t.Errorf("Name() = %q, want %q", p.Name(), "hyprland") + } +} + +func TestHyprlandProviderGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + content := `##! Window Management +bind = SUPER, Q, killactive +bind = SUPER, F, fullscreen, 0 + +###! Movement +bind = SUPER, left, movefocus, l +bind = SUPER, right, movefocus, r + +##! Applications +bind = SUPER, T, exec, kitty # Terminal +bind = SUPER, 1, workspace, 1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + p := NewHyprlandProvider(tmpDir) + sheet, err := p.GetCheatSheet() + + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet.Title != "Hyprland Keybinds" { + t.Errorf("Title = %q, want %q", sheet.Title, "Hyprland Keybinds") + } + + if sheet.Provider != "hyprland" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "hyprland") + } + + if len(sheet.Binds) == 0 { + t.Error("expected categorized bindings, got none") + } + + if windowBinds, ok := sheet.Binds["Window"]; !ok || len(windowBinds) == 0 { + t.Error("expected Window category with bindings") + } + + if execBinds, ok := sheet.Binds["Execute"]; !ok || len(execBinds) == 0 { + t.Error("expected Execute category with bindings") + } + + if wsBinds, ok := sheet.Binds["Workspace"]; !ok || len(wsBinds) == 0 { + t.Error("expected Workspace category with bindings") + } +} + +func TestHyprlandProviderGetCheatSheetError(t *testing.T) { + p := NewHyprlandProvider("/nonexistent/path") + _, err := p.GetCheatSheet() + + if err == nil { + t.Error("expected error for nonexistent path, got nil") + } +} + +func TestFormatKey(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + tests := []struct { + name string + content string + expected string + category string + }{ + { + name: "single mod", + content: "bind = SUPER, Q, killactive", + expected: "SUPER+Q", + category: "Window", + }, + { + name: "multiple mods", + content: "bind = SUPER+SHIFT, F, fullscreen, 0", + expected: "SUPER+SHIFT+F", + category: "Window", + }, + { + name: "no mods", + content: "bind = , Print, exec, screenshot", + expected: "Print", + category: "Execute", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + p := NewHyprlandProvider(tmpDir) + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + categoryBinds, ok := sheet.Binds[tt.category] + if !ok || len(categoryBinds) == 0 { + t.Fatalf("expected binds in category %q", tt.category) + } + + if categoryBinds[0].Key != tt.expected { + t.Errorf("Key = %q, want %q", categoryBinds[0].Key, tt.expected) + } + }) + } +} + +func TestDescriptionFallback(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + tests := []struct { + name string + content string + wantDesc string + }{ + { + name: "autogenerated description for known dispatcher", + content: "bind = SUPER, Q, killactive", + wantDesc: "Close window", + }, + { + name: "custom comment overrides autogeneration", + content: "bind = SUPER, T, exec, kitty # Open terminal", + wantDesc: "Open terminal", + }, + { + name: "fallback for unknown dispatcher without params", + content: "bind = SUPER, W, unknowndispatcher", + wantDesc: "unknowndispatcher", + }, + { + name: "fallback for unknown dispatcher with params", + content: "bind = SUPER, X, customdispatcher, arg1", + wantDesc: "customdispatcher arg1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + p := NewHyprlandProvider(tmpDir) + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + found := false + for _, binds := range sheet.Binds { + for _, bind := range binds { + if bind.Description == tt.wantDesc { + found = true + break + } + } + if found { + break + } + } + + if !found { + t.Errorf("expected description %q not found in any bind", tt.wantDesc) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go new file mode 100644 index 0000000..08cf449 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go @@ -0,0 +1,119 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type JSONFileProvider struct { + filePath string + name string +} + +func NewJSONFileProvider(filePath string) (*JSONFileProvider, error) { + if filePath == "" { + return nil, fmt.Errorf("file path cannot be empty") + } + + expandedPath, err := utils.ExpandPath(filePath) + if err != nil { + return nil, fmt.Errorf("failed to expand path: %w", err) + } + + name := filepath.Base(expandedPath) + name = name[:len(name)-len(filepath.Ext(name))] + + return &JSONFileProvider{ + filePath: expandedPath, + name: name, + }, nil +} + +func (j *JSONFileProvider) Name() string { + return j.name +} + +func (j *JSONFileProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + data, err := os.ReadFile(j.filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var rawData map[string]any + if err := json.Unmarshal(data, &rawData); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + title, _ := rawData["title"].(string) + provider, _ := rawData["provider"].(string) + if provider == "" { + provider = j.name + } + + categorizedBinds := make(map[string][]keybinds.Keybind) + + bindsRaw, ok := rawData["binds"] + if !ok { + return nil, fmt.Errorf("missing 'binds' field") + } + + switch binds := bindsRaw.(type) { + case map[string]any: + for category, categoryBindsRaw := range binds { + categoryBindsList, ok := categoryBindsRaw.([]any) + if !ok { + continue + } + + var keybindsList []keybinds.Keybind + categoryBindsJSON, _ := json.Marshal(categoryBindsList) + if err := json.Unmarshal(categoryBindsJSON, &keybindsList); err != nil { + continue + } + + categorizedBinds[category] = keybindsList + } + + case []any: + flatBindsJSON, _ := json.Marshal(binds) + var flatBinds []struct { + Key string `json:"key"` + Description string `json:"desc"` + Action string `json:"action,omitempty"` + Category string `json:"cat,omitempty"` + Subcategory string `json:"subcat,omitempty"` + } + if err := json.Unmarshal(flatBindsJSON, &flatBinds); err != nil { + return nil, fmt.Errorf("failed to parse flat binds array: %w", err) + } + + for _, bind := range flatBinds { + category := bind.Category + if category == "" { + category = "Other" + } + + kb := keybinds.Keybind{ + Key: bind.Key, + Description: bind.Description, + Action: bind.Action, + Subcategory: bind.Subcategory, + } + categorizedBinds[category] = append(categorizedBinds[category], kb) + } + + default: + return nil, fmt.Errorf("'binds' must be either an object (categorized) or array (flat)") + } + + return &keybinds.CheatSheet{ + Title: title, + Provider: provider, + Binds: categorizedBinds, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go new file mode 100644 index 0000000..ded4385 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go @@ -0,0 +1,281 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestNewJSONFileProvider(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.json") + + if err := os.WriteFile(testFile, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + tests := []struct { + name string + filePath string + expectError bool + wantName string + }{ + { + name: "valid file", + filePath: testFile, + expectError: false, + wantName: "test", + }, + { + name: "empty path", + filePath: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := NewJSONFileProvider(tt.filePath) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if p.Name() != tt.wantName { + t.Errorf("Name() = %q, want %q", p.Name(), tt.wantName) + } + }) + } +} + +func TestJSONFileProviderGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "tmux.json") + + content := `{ + "title": "Tmux Binds", + "provider": "tmux", + "binds": { + "Pane": [ + { + "key": "Ctrl+Alt+J", + "desc": "Resize split downward", + "subcat": "Sizing" + }, + { + "key": "Ctrl+K", + "desc": "Move Focus Up", + "subcat": "Navigation" + } + ] + } +}` + + if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet.Title != "Tmux Binds" { + t.Errorf("Title = %q, want %q", sheet.Title, "Tmux Binds") + } + + if sheet.Provider != "tmux" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "tmux") + } + + paneBinds, ok := sheet.Binds["Pane"] + if !ok { + t.Fatal("expected Pane category") + } + + if len(paneBinds) != 2 { + t.Errorf("len(Pane binds) = %d, want 2", len(paneBinds)) + } + + if len(paneBinds) > 0 { + bind := paneBinds[0] + if bind.Key != "Ctrl+Alt+J" { + t.Errorf("Pane[0].Key = %q, want %q", bind.Key, "Ctrl+Alt+J") + } + if bind.Description != "Resize split downward" { + t.Errorf("Pane[0].Description = %q, want %q", bind.Description, "Resize split downward") + } + if bind.Subcategory != "Sizing" { + t.Errorf("Pane[0].Subcategory = %q, want %q", bind.Subcategory, "Sizing") + } + } +} + +func TestJSONFileProviderGetCheatSheetNoProvider(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "custom.json") + + content := `{ + "title": "Custom Binds", + "binds": {} +}` + + if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet.Provider != "custom" { + t.Errorf("Provider = %q, want %q (should default to filename)", sheet.Provider, "custom") + } +} + +func TestJSONFileProviderFlatArrayBackwardsCompat(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy.json") + + content := `{ + "title": "Legacy Format", + "provider": "legacy", + "binds": [ + { + "key": "Ctrl+S", + "desc": "Save file", + "cat": "File", + "subcat": "Operations" + }, + { + "key": "Ctrl+O", + "desc": "Open file", + "cat": "File" + }, + { + "key": "Ctrl+Q", + "desc": "Quit", + "subcat": "Exit" + } + ] +}` + + if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + fileBinds, ok := sheet.Binds["File"] + if !ok || len(fileBinds) != 2 { + t.Errorf("expected 2 binds in File category, got %d", len(fileBinds)) + } + + otherBinds, ok := sheet.Binds["Other"] + if !ok || len(otherBinds) != 1 { + t.Errorf("expected 1 bind in Other category (no cat specified), got %d", len(otherBinds)) + } + + if len(fileBinds) > 0 { + if fileBinds[0].Subcategory != "Operations" { + t.Errorf("expected subcategory %q, got %q", "Operations", fileBinds[0].Subcategory) + } + } +} + +func TestJSONFileProviderInvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "invalid.json") + + if err := os.WriteFile(testFile, []byte("not valid json"), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + _, err = p.GetCheatSheet() + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } +} + +func TestJSONFileProviderNonexistentFile(t *testing.T) { + p, err := NewJSONFileProvider("/nonexistent/file.json") + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + _, err = p.GetCheatSheet() + if err == nil { + t.Error("expected error for nonexistent file, got nil") + } +} + +func TestExpandPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "tilde expansion", + input: "~/test", + expected: filepath.Join(home, "test"), + }, + { + name: "no expansion needed", + input: "/absolute/path", + expected: "/absolute/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := utils.ExpandPath(tt.input) + if err != nil { + t.Fatalf("expandPath failed: %v", err) + } + + if result != tt.expected { + t.Errorf("utils.ExpandPath(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go new file mode 100644 index 0000000..348ee7c --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go @@ -0,0 +1,442 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type MangoWCProvider struct { + configPath string + dmsBindsIncluded bool + parsed bool +} + +func NewMangoWCProvider(configPath string) *MangoWCProvider { + if configPath == "" { + configPath = defaultMangoWCConfigDir() + } + return &MangoWCProvider{ + configPath: configPath, + } +} + +func defaultMangoWCConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "mango") +} + +func (m *MangoWCProvider) Name() string { + return "mangowc" +} + +func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + result, err := ParseMangoWCKeysWithDMS(m.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse mangowc config: %w", err) + } + + m.dmsBindsIncluded = result.DMSBindsIncluded + m.parsed = true + + categorizedBinds := make(map[string][]keybinds.Keybind) + for _, kb := range result.Keybinds { + category := m.categorizeByCommand(kb.Command) + bind := m.convertKeybind(&kb, result.ConflictingConfigs) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + sheet := &keybinds.CheatSheet{ + Title: "MangoWC Keybinds", + Provider: m.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, nil +} + +func (m *MangoWCProvider) HasDMSBindsIncluded() bool { + if m.parsed { + return m.dmsBindsIncluded + } + + result, err := ParseMangoWCKeysWithDMS(m.configPath) + if err != nil { + return false + } + + m.dmsBindsIncluded = result.DMSBindsIncluded + m.parsed = true + return m.dmsBindsIncluded +} + +func (m *MangoWCProvider) categorizeByCommand(command string) string { + switch { + case strings.Contains(command, "mon"): + return "Monitor" + case command == "toggleoverview": + return "Overview" + case command == "toggle_scratchpad": + return "Scratchpad" + case strings.Contains(command, "layout") || strings.Contains(command, "proportion"): + return "Layout" + case strings.Contains(command, "gaps"): + return "Gaps" + case strings.Contains(command, "view") || strings.Contains(command, "tag"): + return "Tags" + case command == "focusstack" || + command == "focusdir" || + command == "exchange_client" || + command == "killclient" || + command == "togglefloating" || + command == "togglefullscreen" || + command == "togglefakefullscreen" || + command == "togglemaximizescreen" || + command == "toggleglobal" || + command == "toggleoverlay" || + command == "minimized" || + command == "restore_minimized" || + command == "movewin" || + command == "resizewin": + return "Window" + case command == "spawn" || command == "spawn_shell": + return "Execute" + case command == "quit" || command == "reload_config": + return "System" + default: + return "Other" + } +} + +func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind { + keyStr := m.formatKey(kb) + rawAction := m.formatRawAction(kb.Command, kb.Params) + desc := kb.Comment + + if desc == "" { + desc = rawAction + } + + source := "config" + if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, + Description: desc, + Action: rawAction, + Source: source, + } + + if source == "dms" && conflicts != nil { + normalizedKey := strings.ToLower(keyStr) + if conflictKb, ok := conflicts[normalizedKey]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Comment, + Action: m.formatRawAction(conflictKb.Command, conflictKb.Params), + Source: "config", + } + } + } + + return bind +} + +func (m *MangoWCProvider) formatRawAction(command, params string) string { + if params != "" { + return command + " " + params + } + return command +} + +func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (m *MangoWCProvider) GetOverridePath() string { + expanded, err := utils.ExpandPath(m.configPath) + if err != nil { + return filepath.Join(m.configPath, "dms", "binds.conf") + } + return filepath.Join(expanded, "dms", "binds.conf") +} + +func (m *MangoWCProvider) 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 action == "spawn_shell" || action == "spawn_shell ": + return fmt.Errorf("spawn_shell command requires arguments") + case strings.HasPrefix(action, "spawn "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn ")) + if rest == "" { + return fmt.Errorf("spawn command requires arguments") + } + case strings.HasPrefix(action, "spawn_shell "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell ")) + if rest == "" { + return fmt.Errorf("spawn_shell command requires arguments") + } + } + return nil +} + +func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error { + if err := m.validateAction(action); err != nil { + return err + } + + overridePath := m.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + existingBinds, err := m.loadOverrideBinds() + if err != nil { + existingBinds = make(map[string]*mangowcOverrideBind) + } + + normalizedKey := strings.ToLower(key) + existingBinds[normalizedKey] = &mangowcOverrideBind{ + Key: key, + Action: action, + Description: description, + Options: options, + } + + return m.writeOverrideBinds(existingBinds) +} + +func (m *MangoWCProvider) RemoveBind(key string) error { + existingBinds, err := m.loadOverrideBinds() + if err != nil { + return nil + } + + normalizedKey := strings.ToLower(key) + delete(existingBinds, normalizedKey) + return m.writeOverrideBinds(existingBinds) +} + +type mangowcOverrideBind struct { + Key string + Action string + Description string + Options map[string]any +} + +func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) { + overridePath := m.GetOverridePath() + binds := make(map[string]*mangowcOverrideBind) + + data, err := os.ReadFile(overridePath) + if os.IsNotExist(err) { + return binds, nil + } + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if !strings.HasPrefix(line, "bind") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + + content := strings.TrimSpace(parts[1]) + commentParts := strings.SplitN(content, "#", 2) + bindContent := strings.TrimSpace(commentParts[0]) + + var comment string + if len(commentParts) > 1 { + comment = strings.TrimSpace(commentParts[1]) + } + + fields := strings.SplitN(bindContent, ",", 4) + if len(fields) < 3 { + continue + } + + mods := strings.TrimSpace(fields[0]) + keyName := strings.TrimSpace(fields[1]) + command := strings.TrimSpace(fields[2]) + + var params string + if len(fields) > 3 { + params = strings.TrimSpace(fields[3]) + } + + keyStr := m.buildKeyString(mods, keyName) + normalizedKey := strings.ToLower(keyStr) + action := command + if params != "" { + action = command + " " + params + } + + binds[normalizedKey] = &mangowcOverrideBind{ + Key: keyStr, + Action: action, + Description: comment, + } + } + + return binds, nil +} + +func (m *MangoWCProvider) buildKeyString(mods, key string) string { + if mods == "" || strings.EqualFold(mods, "none") { + return key + } + + modList := strings.FieldsFunc(mods, func(r rune) bool { + return r == '+' || r == ' ' + }) + + parts := append(modList, key) + return strings.Join(parts, "+") +} + +func (m *MangoWCProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "view") || strings.Contains(action, "tag"): + return 1 + case strings.Contains(action, "focus") || strings.Contains(action, "exchange") || + strings.Contains(action, "resize") || strings.Contains(action, "move"): + return 2 + case strings.Contains(action, "mon"): + return 3 + case strings.HasPrefix(action, "spawn"): + return 4 + case action == "quit" || action == "reload_config": + return 5 + default: + return 6 + } +} + +func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error { + overridePath := m.GetOverridePath() + content := m.generateBindsContent(binds) + return os.WriteFile(overridePath, []byte(content), 0o644) +} + +func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string { + if len(binds) == 0 { + return "" + } + + bindList := make([]*mangowcOverrideBind, 0, len(binds)) + for _, bind := range binds { + bindList = append(bindList, bind) + } + + sort.Slice(bindList, func(i, j int) bool { + pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action) + if pi != pj { + return pi < pj + } + return bindList[i].Key < bindList[j].Key + }) + + var sb strings.Builder + for _, bind := range bindList { + m.writeBindLine(&sb, bind) + } + + return sb.String() +} + +func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) { + mods, key := m.parseKeyString(bind.Key) + command, params := m.parseAction(bind.Action) + + sb.WriteString("bind=") + if mods == "" { + sb.WriteString("none") + } else { + sb.WriteString(mods) + } + sb.WriteString(",") + sb.WriteString(key) + sb.WriteString(",") + sb.WriteString(command) + + if params != "" { + sb.WriteString(",") + sb.WriteString(params) + } + + if bind.Description != "" { + sb.WriteString(" # ") + sb.WriteString(bind.Description) + } + + sb.WriteString("\n") +} + +func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) { + parts := strings.Split(keyStr, "+") + switch len(parts) { + case 0: + return "", keyStr + case 1: + return "", parts[0] + default: + return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1] + } +} + +func (m *MangoWCProvider) parseAction(action string) (command, params string) { + parts := strings.SplitN(action, " ", 2) + switch len(parts) { + case 0: + return action, "" + case 1: + return parts[0], "" + default: + return parts[0], parts[1] + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go new file mode 100644 index 0000000..5dac4b6 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go @@ -0,0 +1,613 @@ +package providers + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +const ( + MangoWCHideComment = "[hidden]" +) + +var MangoWCModSeparators = []rune{'+', ' '} + +type MangoWCKeyBinding struct { + Mods []string `json:"mods"` + Key string `json:"key"` + Command string `json:"command"` + Params string `json:"params"` + Comment string `json:"comment"` + Source string `json:"source"` +} + +type MangoWCParser struct { + contentLines []string + readingLine int + configDir string + currentSource string + dmsBindsExists bool + dmsBindsIncluded bool + includeCount int + dmsIncludePos int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + conflictingConfigs map[string]*MangoWCKeyBinding + bindMap map[string]*MangoWCKeyBinding + bindOrder []string + processedFiles map[string]bool + dmsProcessed bool +} + +func NewMangoWCParser(configDir string) *MangoWCParser { + return &MangoWCParser{ + contentLines: []string{}, + readingLine: 0, + configDir: configDir, + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + conflictingConfigs: make(map[string]*MangoWCKeyBinding), + bindMap: make(map[string]*MangoWCKeyBinding), + bindOrder: []string{}, + processedFiles: make(map[string]bool), + } +} + +func (p *MangoWCParser) ReadContent(path string) error { + expandedPath, err := utils.ExpandPath(path) + if err != nil { + return err + } + + info, err := os.Stat(expandedPath) + if err != nil { + return err + } + + var files []string + if info.IsDir() { + confFiles, err := filepath.Glob(filepath.Join(expandedPath, "*.conf")) + if err != nil { + return err + } + if len(confFiles) == 0 { + return os.ErrNotExist + } + files = confFiles + } else { + files = []string{expandedPath} + } + + var combinedContent []string + for _, file := range files { + if fileInfo, err := os.Stat(file); err == nil && fileInfo.Mode().IsRegular() { + data, err := os.ReadFile(file) + if err == nil { + combinedContent = append(combinedContent, string(data)) + } + } + } + + if len(combinedContent) == 0 { + return os.ErrNotExist + } + + fullContent := strings.Join(combinedContent, "\n") + p.contentLines = strings.Split(fullContent, "\n") + return nil +} + +func mangowcAutogenerateComment(command, params string) string { + switch command { + case "spawn", "spawn_shell": + return params + case "killclient": + return "Close window" + case "quit": + return "Exit MangoWC" + case "reload_config": + return "Reload configuration" + case "focusstack": + if params == "next" { + return "Focus next window" + } + if params == "prev" { + return "Focus previous window" + } + return "Focus stack " + params + case "focusdir": + dirMap := map[string]string{ + "left": "left", + "right": "right", + "up": "up", + "down": "down", + } + if dir, ok := dirMap[params]; ok { + return "Focus " + dir + } + return "Focus " + params + case "exchange_client": + dirMap := map[string]string{ + "left": "left", + "right": "right", + "up": "up", + "down": "down", + } + if dir, ok := dirMap[params]; ok { + return "Swap window " + dir + } + return "Swap window " + params + case "togglefloating": + return "Float/unfloat window" + case "togglefullscreen": + return "Toggle fullscreen" + case "togglefakefullscreen": + return "Toggle fake fullscreen" + case "togglemaximizescreen": + return "Toggle maximize" + case "toggleglobal": + return "Toggle global" + case "toggleoverview": + return "Toggle overview" + case "toggleoverlay": + return "Toggle overlay" + case "minimized": + return "Minimize window" + case "restore_minimized": + return "Restore minimized" + case "toggle_scratchpad": + return "Toggle scratchpad" + case "setlayout": + return "Set layout " + params + case "switch_layout": + return "Switch layout" + case "view": + parts := strings.Split(params, ",") + if len(parts) > 0 { + return "View tag " + parts[0] + } + return "View tag" + case "tag": + parts := strings.Split(params, ",") + if len(parts) > 0 { + return "Move to tag " + parts[0] + } + return "Move to tag" + case "toggleview": + parts := strings.Split(params, ",") + if len(parts) > 0 { + return "Toggle tag " + parts[0] + } + return "Toggle tag" + case "viewtoleft", "viewtoleft_have_client": + return "View left tag" + case "viewtoright", "viewtoright_have_client": + return "View right tag" + case "tagtoleft": + return "Move to left tag" + case "tagtoright": + return "Move to right tag" + case "focusmon": + return "Focus monitor " + params + case "tagmon": + return "Move to monitor " + params + case "incgaps": + if strings.HasPrefix(params, "-") { + return "Decrease gaps" + } + return "Increase gaps" + case "togglegaps": + return "Toggle gaps" + case "movewin": + return "Move window by " + params + case "resizewin": + return "Resize window by " + params + case "set_proportion": + return "Set proportion " + params + case "switch_proportion_preset": + return "Switch proportion preset" + default: + return "" + } +} + +func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding { + if lineNumber >= len(p.contentLines) { + return nil + } + + line := p.contentLines[lineNumber] + + bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`) + matches := bindMatch.FindStringSubmatch(line) + if len(matches) < 3 { + return nil + } + + bindType := matches[1] + content := matches[2] + + parts := strings.SplitN(content, "#", 2) + keys := parts[0] + + var comment string + if len(parts) > 1 { + comment = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(comment, MangoWCHideComment) { + return nil + } + + keyFields := strings.SplitN(keys, ",", 4) + if len(keyFields) < 3 { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + command := strings.TrimSpace(keyFields[2]) + + var params string + if len(keyFields) > 3 { + params = strings.TrimSpace(keyFields[3]) + } + + if comment == "" { + comment = mangowcAutogenerateComment(command, params) + } + + var modList []string + if mods != "" && !strings.EqualFold(mods, "none") { + modstring := mods + string(MangoWCModSeparators[0]) + p := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range MangoWCModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-p > 1 { + modList = append(modList, modstring[p:index]) + } + p = index + 1 + } + } + } + + _ = bindType + + return &MangoWCKeyBinding{ + Mods: modList, + Key: key, + Command: command, + Params: params, + Comment: comment, + } +} + +func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding { + var keybinds []MangoWCKeyBinding + + for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ { + line := p.contentLines[lineNumber] + if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + + if !strings.HasPrefix(strings.TrimSpace(line), "bind") { + continue + } + + keybind := p.getKeybindAtLine(lineNumber) + if keybind != nil { + keybinds = append(keybinds, *keybind) + } + } + + return keybinds +} + +func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) { + parser := NewMangoWCParser(path) + if err := parser.ReadContent(path); err != nil { + return nil, err + } + return parser.ParseKeys(), nil +} + +type MangoWCParseResult struct { + Keybinds []MangoWCKeyBinding + DMSBindsIncluded bool + DMSStatus *MangoWCDMSStatus + ConflictingConfigs map[string]*MangoWCKeyBinding +} + +type MangoWCDMSStatus struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus { + status := &MangoWCDMSStatus{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.conf does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.conf is not sourced in config" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *MangoWCParser) normalizeKey(key string) string { + return strings.ToLower(key) +} + +func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) { + key := p.formatBindKey(kb) + normalizedKey := p.normalizeKey(key) + isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf") + + if isDMSBind { + p.dmsBindKeys[normalizedKey] = true + } else if p.dmsBindKeys[normalizedKey] { + p.bindsAfterDMS++ + p.conflictingConfigs[normalizedKey] = kb + p.configBindKeys[normalizedKey] = true + return + } else { + p.configBindKeys[normalizedKey] = true + } + + if _, exists := p.bindMap[normalizedKey]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[normalizedKey] = kb +} + +func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) { + expandedDir, err := utils.ExpandPath(p.configDir) + if err != nil { + return nil, err + } + + dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + mainConfig := filepath.Join(expandedDir, "config.conf") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + mainConfig = filepath.Join(expandedDir, "mango.conf") + } + + _, err = p.parseFileWithSource(mainConfig) + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath) + } + + var keybinds []MangoWCKeyBinding + for _, key := range p.bindOrder { + normalizedKey := p.normalizeKey(key) + if kb, exists := p.bindMap[normalizedKey]; exists { + keybinds = append(keybinds, *kb) + } + } + + return keybinds, nil +} + +func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + if p.processedFiles[absPath] { + return nil, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + prevSource := p.currentSource + p.currentSource = absPath + + var keybinds []MangoWCKeyBinding + lines := strings.Split(string(data), "\n") + + for lineNum, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "source") { + p.handleSource(trimmed, filepath.Dir(absPath), &keybinds) + continue + } + + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.getKeybindAtLineContent(line, lineNum) + if kb == nil { + continue + } + kb.Source = p.currentSource + p.addBind(kb) + keybinds = append(keybinds, *kb) + } + + p.currentSource = prevSource + return keybinds, nil +} + +func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return + } + + sourcePath := strings.TrimSpace(parts[1]) + isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") + + p.includeCount++ + if isDMSSource { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + expanded, err := utils.ExpandPath(sourcePath) + if err != nil { + return + } + + fullPath := expanded + if !filepath.IsAbs(expanded) { + fullPath = filepath.Join(baseDir, expanded) + } + + includedBinds, err := p.parseFileWithSource(fullPath) + if err != nil { + return + } + + *keybinds = append(*keybinds, includedBinds...) +} + +func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding { + keybinds, err := p.parseFileWithSource(dmsBindsPath) + if err != nil { + return nil + } + p.dmsProcessed = true + return keybinds +} + +func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding { + bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`) + matches := bindMatch.FindStringSubmatch(line) + if len(matches) < 3 { + return nil + } + + content := matches[2] + parts := strings.SplitN(content, "#", 2) + keys := parts[0] + + var comment string + if len(parts) > 1 { + comment = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(comment, MangoWCHideComment) { + return nil + } + + keyFields := strings.SplitN(keys, ",", 4) + if len(keyFields) < 3 { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + command := strings.TrimSpace(keyFields[2]) + + var params string + if len(keyFields) > 3 { + params = strings.TrimSpace(keyFields[3]) + } + + if comment == "" { + comment = mangowcAutogenerateComment(command, params) + } + + var modList []string + if mods != "" && !strings.EqualFold(mods, "none") { + modstring := mods + string(MangoWCModSeparators[0]) + idx := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range MangoWCModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-idx > 1 { + modList = append(modList, modstring[idx:index]) + } + idx = index + 1 + } + } + } + + return &MangoWCKeyBinding{ + Mods: modList, + Key: key, + Command: command, + Params: params, + Comment: comment, + } +} + +func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) { + parser := NewMangoWCParser(path) + keybinds, err := parser.ParseWithDMS() + if err != nil { + return nil, err + } + + return &MangoWCParseResult{ + Keybinds: keybinds, + DMSBindsIncluded: parser.dmsBindsIncluded, + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go new file mode 100644 index 0000000..016f50e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go @@ -0,0 +1,499 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMangoWCAutogenerateComment(t *testing.T) { + tests := []struct { + command string + params string + expected string + }{ + {"spawn", "kitty", "kitty"}, + {"spawn_shell", "firefox", "firefox"}, + {"killclient", "", "Close window"}, + {"quit", "", "Exit MangoWC"}, + {"reload_config", "", "Reload configuration"}, + {"focusstack", "next", "Focus next window"}, + {"focusstack", "prev", "Focus previous window"}, + {"focusdir", "left", "Focus left"}, + {"focusdir", "right", "Focus right"}, + {"focusdir", "up", "Focus up"}, + {"focusdir", "down", "Focus down"}, + {"exchange_client", "left", "Swap window left"}, + {"exchange_client", "right", "Swap window right"}, + {"togglefloating", "", "Float/unfloat window"}, + {"togglefullscreen", "", "Toggle fullscreen"}, + {"togglefakefullscreen", "", "Toggle fake fullscreen"}, + {"togglemaximizescreen", "", "Toggle maximize"}, + {"toggleglobal", "", "Toggle global"}, + {"toggleoverview", "", "Toggle overview"}, + {"toggleoverlay", "", "Toggle overlay"}, + {"minimized", "", "Minimize window"}, + {"restore_minimized", "", "Restore minimized"}, + {"toggle_scratchpad", "", "Toggle scratchpad"}, + {"setlayout", "tile", "Set layout tile"}, + {"switch_layout", "", "Switch layout"}, + {"view", "1,0", "View tag 1"}, + {"tag", "2,0", "Move to tag 2"}, + {"toggleview", "3,0", "Toggle tag 3"}, + {"viewtoleft", "", "View left tag"}, + {"viewtoright", "", "View right tag"}, + {"viewtoleft_have_client", "", "View left tag"}, + {"viewtoright_have_client", "", "View right tag"}, + {"tagtoleft", "", "Move to left tag"}, + {"tagtoright", "", "Move to right tag"}, + {"focusmon", "left", "Focus monitor left"}, + {"tagmon", "right", "Move to monitor right"}, + {"incgaps", "1", "Increase gaps"}, + {"incgaps", "-1", "Decrease gaps"}, + {"togglegaps", "", "Toggle gaps"}, + {"movewin", "+0,-50", "Move window by +0,-50"}, + {"resizewin", "+0,+50", "Resize window by +0,+50"}, + {"set_proportion", "1.0", "Set proportion 1.0"}, + {"switch_proportion_preset", "", "Switch proportion preset"}, + {"unknown", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.command+"_"+tt.params, func(t *testing.T) { + result := mangowcAutogenerateComment(tt.command, tt.params) + if result != tt.expected { + t.Errorf("mangowcAutogenerateComment(%q, %q) = %q, want %q", + tt.command, tt.params, result, tt.expected) + } + }) + } +} + +func TestMangoWCGetKeybindAtLine(t *testing.T) { + tests := []struct { + name string + line string + expected *MangoWCKeyBinding + }{ + { + name: "basic_keybind", + line: "bind=ALT,q,killclient,", + expected: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "q", + Command: "killclient", + Params: "", + Comment: "Close window", + }, + }, + { + name: "keybind_with_params", + line: "bind=ALT,Left,focusdir,left", + expected: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "Left", + Command: "focusdir", + Params: "left", + Comment: "Focus left", + }, + }, + { + name: "keybind_with_comment", + line: "bind=Alt,t,spawn,kitty # Open terminal", + expected: &MangoWCKeyBinding{ + Mods: []string{"Alt"}, + Key: "t", + Command: "spawn", + Params: "kitty", + Comment: "Open terminal", + }, + }, + { + name: "keybind_hidden", + line: "bind=SUPER,h,spawn,secret # [hidden]", + expected: nil, + }, + { + name: "keybind_multiple_mods", + line: "bind=SUPER+SHIFT,Up,exchange_client,up", + expected: &MangoWCKeyBinding{ + Mods: []string{"SUPER", "SHIFT"}, + Key: "Up", + Command: "exchange_client", + Params: "up", + Comment: "Swap window up", + }, + }, + { + name: "keybind_no_mods", + line: "bind=NONE,Print,spawn,screenshot", + expected: &MangoWCKeyBinding{ + Mods: []string{}, + Key: "Print", + Command: "spawn", + Params: "screenshot", + Comment: "screenshot", + }, + }, + { + name: "keybind_multiple_params", + line: "bind=Ctrl,1,view,1,0", + expected: &MangoWCKeyBinding{ + Mods: []string{"Ctrl"}, + Key: "1", + Command: "view", + Params: "1,0", + Comment: "View tag 1", + }, + }, + { + name: "bindl_flag", + line: "bindl=SUPER+ALT,l,spawn,dms ipc call lock lock", + expected: &MangoWCKeyBinding{ + Mods: []string{"SUPER", "ALT"}, + Key: "l", + Command: "spawn", + Params: "dms ipc call lock lock", + Comment: "dms ipc call lock lock", + }, + }, + { + name: "keybind_with_spaces", + line: "bind = SUPER, r, reload_config", + expected: &MangoWCKeyBinding{ + Mods: []string{"SUPER"}, + Key: "r", + Command: "reload_config", + Params: "", + Comment: "Reload configuration", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewMangoWCParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if tt.expected == nil { + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + return + } + + if result == nil { + t.Errorf("expected %+v, got nil", tt.expected) + return + } + + if result.Key != tt.expected.Key { + t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key) + } + if result.Command != tt.expected.Command { + t.Errorf("Command = %q, want %q", result.Command, tt.expected.Command) + } + if result.Params != tt.expected.Params { + t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params) + } + if result.Comment != tt.expected.Comment { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment) + } + if len(result.Mods) != len(tt.expected.Mods) { + t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods)) + } else { + for i := range result.Mods { + if result.Mods[i] != tt.expected.Mods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i]) + } + } + } + }) + } +} + +func TestMangoWCParseKeys(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `# MangoWC Configuration +blur=0 +border_radius=12 + +# Key Bindings +bind=SUPER,r,reload_config +bind=Alt,t,spawn,kitty # Terminal +bind=ALT,q,killclient, +bind=ALT,Left,focusdir,left + +# Hidden binding +bind=SUPER,h,spawn,secret # [hidden] + +# Multiple modifiers +bind=SUPER+SHIFT,Up,exchange_client,up + +# Workspace bindings +bind=Ctrl,1,view,1,0 +bind=Ctrl,2,view,2,0 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + keybinds, err := ParseMangoWCKeys(configFile) + if err != nil { + t.Fatalf("ParseMangoWCKeys failed: %v", err) + } + + expectedCount := 7 + if len(keybinds) != expectedCount { + t.Errorf("Expected %d keybinds, got %d", expectedCount, len(keybinds)) + } + + if len(keybinds) > 0 && keybinds[0].Command != "reload_config" { + t.Errorf("First keybind command = %q, want %q", keybinds[0].Command, "reload_config") + } + + foundHidden := false + for _, kb := range keybinds { + if kb.Command == "spawn" && kb.Params == "secret" { + foundHidden = true + } + } + if foundHidden { + t.Error("Hidden keybind should not be included in results") + } +} + +func TestMangoWCReadContentMultipleFiles(t *testing.T) { + tmpDir := t.TempDir() + + file1 := filepath.Join(tmpDir, "a.conf") + file2 := filepath.Join(tmpDir, "b.conf") + + content1 := "bind=ALT,q,killclient,\n" + content2 := "bind=Alt,t,spawn,kitty\n" + + if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil { + t.Fatalf("Failed to write file1: %v", err) + } + if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil { + t.Fatalf("Failed to write file2: %v", err) + } + + parser := NewMangoWCParser("") + if err := parser.ReadContent(tmpDir); err != nil { + t.Fatalf("ReadContent failed: %v", err) + } + + keybinds := parser.ParseKeys() + if len(keybinds) != 2 { + t.Errorf("Expected 2 keybinds from multiple files, got %d", len(keybinds)) + } +} + +func TestMangoWCReadContentSingleFile(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := "bind=ALT,q,killclient,\n" + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + parser := NewMangoWCParser("") + if err := parser.ReadContent(configFile); err != nil { + t.Fatalf("ReadContent failed: %v", err) + } + + keybinds := parser.ParseKeys() + if len(keybinds) != 1 { + t.Errorf("Expected 1 keybind, got %d", len(keybinds)) + } +} + +func TestMangoWCReadContentErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + { + name: "empty_directory", + path: t.TempDir(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseMangoWCKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestMangoWCReadContentWithTildeExpansion(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name()) + if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { + t.Skip("Cannot create test directory in home") + } + defer os.RemoveAll(tmpSubdir) + + configFile := filepath.Join(tmpSubdir, "config.conf") + if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + relPath, err := filepath.Rel(homeDir, tmpSubdir) + if err != nil { + t.Skip("Cannot create relative path") + } + + parser := NewMangoWCParser("") + tildePathMatch := "~/" + relPath + err = parser.ReadContent(tildePathMatch) + + if err != nil { + t.Errorf("ReadContent with tilde path failed: %v", err) + } +} + +func TestMangoWCEmptyAndCommentLines(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := ` +# This is a comment +bind=ALT,q,killclient, + +# Another comment + +bind=Alt,t,spawn,kitty +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + keybinds, err := ParseMangoWCKeys(configFile) + if err != nil { + t.Fatalf("ParseMangoWCKeys failed: %v", err) + } + + if len(keybinds) != 2 { + t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(keybinds)) + } +} + +func TestMangoWCInvalidBindLines(t *testing.T) { + tests := []struct { + name string + line string + }{ + { + name: "missing_parts", + line: "bind=SUPER,q", + }, + { + name: "not_bind", + line: "blur=0", + }, + { + name: "empty_line", + line: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewMangoWCParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if result != nil { + t.Errorf("expected nil for invalid line, got %+v", result) + } + }) + } +} + +func TestMangoWCRealWorldConfig(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `# Application Launchers +bind=Alt,t,spawn,kitty +bind=Alt,space,spawn,dms ipc call spotlight toggle +bind=Alt,v,spawn,dms ipc call clipboard toggle + +# exit +bind=ALT+SHIFT,e,quit +bind=ALT,q,killclient, + +# switch window focus +bind=SUPER,Tab,focusstack,next +bind=ALT,Left,focusdir,left +bind=ALT,Right,focusdir,right + +# tag switch +bind=SUPER,Left,viewtoleft,0 +bind=CTRL,Left,viewtoleft_have_client,0 +bind=SUPER,Right,viewtoright,0 + +bind=Ctrl,1,view,1,0 +bind=Ctrl,2,view,2,0 +bind=Ctrl,3,view,3,0 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + keybinds, err := ParseMangoWCKeys(configFile) + if err != nil { + t.Fatalf("ParseMangoWCKeys failed: %v", err) + } + + if len(keybinds) < 14 { + t.Errorf("Expected at least 14 keybinds, got %d", len(keybinds)) + } + + foundSpawn := false + foundQuit := false + foundView := false + + for _, kb := range keybinds { + if kb.Command == "spawn" && kb.Params == "kitty" { + foundSpawn = true + } + if kb.Command == "quit" { + foundQuit = true + } + if kb.Command == "view" && kb.Params == "1,0" { + foundView = true + } + } + + if !foundSpawn { + t.Error("Did not find spawn kitty keybind") + } + if !foundQuit { + t.Error("Did not find quit keybind") + } + if !foundView { + t.Error("Did not find view workspace 1 keybind") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go new file mode 100644 index 0000000..d417ce1 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go @@ -0,0 +1,320 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMangoWCProviderName(t *testing.T) { + provider := NewMangoWCProvider("") + if provider.Name() != "mangowc" { + t.Errorf("Name() = %q, want %q", provider.Name(), "mangowc") + } +} + +func TestMangoWCProviderDefaultPath(t *testing.T) { + provider := NewMangoWCProvider("") + configDir, err := os.UserConfigDir() + if err != nil { + // Fall back to testing for non-empty path + if provider.configPath == "" { + t.Error("configPath should not be empty") + } + return + } + expected := filepath.Join(configDir, "mango") + if provider.configPath != expected { + t.Errorf("configPath = %q, want %q", provider.configPath, expected) + } +} + +func TestMangoWCProviderCustomPath(t *testing.T) { + customPath := "/custom/path" + provider := NewMangoWCProvider(customPath) + if provider.configPath != customPath { + t.Errorf("configPath = %q, want %q", provider.configPath, customPath) + } +} + +func TestMangoWCCategorizeByCommand(t *testing.T) { + tests := []struct { + command string + expected string + }{ + {"view", "Tags"}, + {"tag", "Tags"}, + {"toggleview", "Tags"}, + {"viewtoleft", "Tags"}, + {"viewtoright", "Tags"}, + {"viewtoleft_have_client", "Tags"}, + {"tagtoleft", "Tags"}, + {"tagtoright", "Tags"}, + {"focusmon", "Monitor"}, + {"tagmon", "Monitor"}, + {"focusstack", "Window"}, + {"focusdir", "Window"}, + {"exchange_client", "Window"}, + {"killclient", "Window"}, + {"togglefloating", "Window"}, + {"togglefullscreen", "Window"}, + {"togglefakefullscreen", "Window"}, + {"togglemaximizescreen", "Window"}, + {"toggleglobal", "Window"}, + {"toggleoverlay", "Window"}, + {"minimized", "Window"}, + {"restore_minimized", "Window"}, + {"movewin", "Window"}, + {"resizewin", "Window"}, + {"toggleoverview", "Overview"}, + {"toggle_scratchpad", "Scratchpad"}, + {"setlayout", "Layout"}, + {"switch_layout", "Layout"}, + {"set_proportion", "Layout"}, + {"switch_proportion_preset", "Layout"}, + {"incgaps", "Gaps"}, + {"togglegaps", "Gaps"}, + {"spawn", "Execute"}, + {"spawn_shell", "Execute"}, + {"quit", "System"}, + {"reload_config", "System"}, + {"unknown_command", "Other"}, + } + + provider := NewMangoWCProvider("") + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := provider.categorizeByCommand(tt.command) + if result != tt.expected { + t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected) + } + }) + } +} + +func TestMangoWCFormatKey(t *testing.T) { + tests := []struct { + name string + keybind *MangoWCKeyBinding + expected string + }{ + { + name: "single_mod", + keybind: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "q", + }, + expected: "ALT+q", + }, + { + name: "multiple_mods", + keybind: &MangoWCKeyBinding{ + Mods: []string{"SUPER", "SHIFT"}, + Key: "Up", + }, + expected: "SUPER+SHIFT+Up", + }, + { + name: "no_mods", + keybind: &MangoWCKeyBinding{ + Mods: []string{}, + Key: "Print", + }, + expected: "Print", + }, + } + + provider := NewMangoWCProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.formatKey(tt.keybind) + if result != tt.expected { + t.Errorf("formatKey() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestMangoWCConvertKeybind(t *testing.T) { + tests := []struct { + name string + keybind *MangoWCKeyBinding + wantKey string + wantDesc string + }{ + { + name: "with_comment", + keybind: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "t", + Command: "spawn", + Params: "kitty", + Comment: "Open terminal", + }, + wantKey: "ALT+t", + wantDesc: "Open terminal", + }, + { + name: "without_comment", + keybind: &MangoWCKeyBinding{ + Mods: []string{"SUPER"}, + Key: "r", + Command: "reload_config", + Params: "", + Comment: "", + }, + wantKey: "SUPER+r", + wantDesc: "reload_config", + }, + { + name: "with_params_no_comment", + keybind: &MangoWCKeyBinding{ + Mods: []string{"CTRL"}, + Key: "1", + Command: "view", + Params: "1,0", + Comment: "", + }, + wantKey: "CTRL+1", + wantDesc: "view 1,0", + }, + } + + provider := NewMangoWCProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.convertKeybind(tt.keybind, nil) + if result.Key != tt.wantKey { + t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey) + } + if result.Description != tt.wantDesc { + t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc) + } + }) + } +} + +func TestMangoWCGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `# MangoWC Configuration +blur=0 + +# Key Bindings +bind=SUPER,r,reload_config +bind=Alt,t,spawn,kitty # Terminal +bind=ALT,q,killclient, + +# Window management +bind=ALT,Left,focusdir,left +bind=ALT,Right,focusdir,right +bind=SUPER+SHIFT,Up,exchange_client,up + +# Tags +bind=Ctrl,1,view,1,0 +bind=Ctrl,2,view,2,0 +bind=Alt,1,tag,1,0 + +# Layout +bind=SUPER,n,switch_layout + +# Gaps +bind=ALT+SHIFT,X,incgaps,1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewMangoWCProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet == nil { + t.Fatal("Expected non-nil CheatSheet") + } + + if sheet.Title != "MangoWC Keybinds" { + t.Errorf("Title = %q, want %q", sheet.Title, "MangoWC Keybinds") + } + + if sheet.Provider != "mangowc" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "mangowc") + } + + categories := []string{"System", "Execute", "Window", "Tags", "Layout", "Gaps"} + for _, category := range categories { + if _, exists := sheet.Binds[category]; !exists { + t.Errorf("Expected category %q to exist", category) + } + } + + if len(sheet.Binds["System"]) < 1 { + t.Error("Expected at least 1 System keybind") + } + if len(sheet.Binds["Execute"]) < 1 { + t.Error("Expected at least 1 Execute keybind") + } + if len(sheet.Binds["Window"]) < 3 { + t.Error("Expected at least 3 Window keybinds") + } + if len(sheet.Binds["Tags"]) < 3 { + t.Error("Expected at least 3 Tags keybinds") + } +} + +func TestMangoWCGetCheatSheetError(t *testing.T) { + provider := NewMangoWCProvider("/nonexistent/path") + _, err := provider.GetCheatSheet() + if err == nil { + t.Error("Expected error for nonexistent path, got nil") + } +} + +func TestMangoWCIntegration(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `bind=Alt,t,spawn,kitty # Open terminal +bind=ALT,q,killclient, +bind=SUPER,r,reload_config # Reload config +bind=ALT,Left,focusdir,left +bind=Ctrl,1,view,1,0 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewMangoWCProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + totalBinds := 0 + for _, binds := range sheet.Binds { + totalBinds += len(binds) + } + + expectedBinds := 5 + if totalBinds != expectedBinds { + t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds) + } + + foundTerminal := false + for _, binds := range sheet.Binds { + for _, bind := range binds { + if bind.Description == "Open terminal" && bind.Key == "Alt+t" { + foundTerminal = true + } + } + } + + if !foundTerminal { + t.Error("Did not find terminal keybind with correct key and description") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go new file mode 100644 index 0000000..43fc58e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go @@ -0,0 +1,95 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type MiracleProvider struct { + configPath string +} + +func NewMiracleProvider(configPath string) *MiracleProvider { + if configPath == "" { + configDir, err := os.UserConfigDir() + if err == nil { + configPath = filepath.Join(configDir, "miracle-wm") + } + } + return &MiracleProvider{configPath: configPath} +} + +func (m *MiracleProvider) Name() string { + return "miracle" +} + +func (m *MiracleProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + config, err := ParseMiracleConfig(m.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse miracle-wm config: %w", err) + } + + bindings := MiracleConfigToBindings(config) + categorizedBinds := make(map[string][]keybinds.Keybind) + + for _, kb := range bindings { + category := m.categorizeAction(kb.Action) + bind := keybinds.Keybind{ + Key: m.formatKey(kb), + Description: kb.Comment, + Action: kb.Action, + } + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + return &keybinds.CheatSheet{ + Title: "Miracle WM Keybinds", + Provider: m.Name(), + Binds: categorizedBinds, + }, nil +} + +func (m *MiracleProvider) GetOverridePath() string { + expanded, err := utils.ExpandPath(m.configPath) + if err != nil { + return filepath.Join(m.configPath, "config.yaml") + } + return filepath.Join(expanded, "config.yaml") +} + +func (m *MiracleProvider) formatKey(kb MiracleKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (m *MiracleProvider) categorizeAction(action string) string { + switch { + case strings.HasPrefix(action, "select_workspace_") || strings.HasPrefix(action, "move_to_workspace_"): + return "Workspace" + case strings.Contains(action, "select_") || strings.Contains(action, "move_"): + return "Window" + case action == "toggle_resize" || strings.HasPrefix(action, "resize_"): + return "Window" + case action == "fullscreen" || action == "toggle_floating" || action == "quit_active_window" || action == "toggle_pinned_to_workspace": + return "Window" + case action == "toggle_tabbing" || action == "toggle_stacking" || action == "request_vertical" || action == "request_horizontal": + return "Layout" + case action == "quit_compositor": + return "System" + case action == "terminal": + return "Execute" + case strings.HasPrefix(action, "magnifier_"): + return "Accessibility" + case strings.HasPrefix(action, "dms ") || strings.Contains(action, "dms ipc"): + return "Execute" + default: + return "Execute" + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go new file mode 100644 index 0000000..425c64a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go @@ -0,0 +1,320 @@ +package providers + +import ( + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "gopkg.in/yaml.v3" +) + +type MiracleConfig struct { + Terminal string `yaml:"terminal"` + ActionKey string `yaml:"action_key"` + DefaultActionOverrides []MiracleActionOverride `yaml:"default_action_overrides"` + CustomActions []MiracleCustomAction `yaml:"custom_actions"` +} + +type MiracleActionOverride struct { + Name string `yaml:"name"` + Action string `yaml:"action"` + Modifiers []string `yaml:"modifiers"` + Key string `yaml:"key"` +} + +type MiracleCustomAction struct { + Command string `yaml:"command"` + Action string `yaml:"action"` + Modifiers []string `yaml:"modifiers"` + Key string `yaml:"key"` +} + +type MiracleKeyBinding struct { + Mods []string + Key string + Action string + Comment string +} + +var miracleDefaultBinds = []MiracleKeyBinding{ + {Mods: []string{"Super"}, Key: "Return", Action: "terminal", Comment: "Open terminal"}, + {Mods: []string{"Super"}, Key: "v", Action: "request_vertical", Comment: "Layout windows vertically"}, + {Mods: []string{"Super"}, Key: "h", Action: "request_horizontal", Comment: "Layout windows horizontally"}, + {Mods: []string{"Super"}, Key: "Up", Action: "select_up", Comment: "Select window above"}, + {Mods: []string{"Super"}, Key: "Down", Action: "select_down", Comment: "Select window below"}, + {Mods: []string{"Super"}, Key: "Left", Action: "select_left", Comment: "Select window left"}, + {Mods: []string{"Super"}, Key: "Right", Action: "select_right", Comment: "Select window right"}, + {Mods: []string{"Super", "Shift"}, Key: "Up", Action: "move_up", Comment: "Move window up"}, + {Mods: []string{"Super", "Shift"}, Key: "Down", Action: "move_down", Comment: "Move window down"}, + {Mods: []string{"Super", "Shift"}, Key: "Left", Action: "move_left", Comment: "Move window left"}, + {Mods: []string{"Super", "Shift"}, Key: "Right", Action: "move_right", Comment: "Move window right"}, + {Mods: []string{"Super"}, Key: "r", Action: "toggle_resize", Comment: "Toggle resize mode"}, + {Mods: []string{"Super"}, Key: "f", Action: "fullscreen", Comment: "Toggle fullscreen"}, + {Mods: []string{"Super", "Shift"}, Key: "q", Action: "quit_active_window", Comment: "Close window"}, + {Mods: []string{"Super", "Shift"}, Key: "e", Action: "quit_compositor", Comment: "Exit compositor"}, + {Mods: []string{"Super"}, Key: "Space", Action: "toggle_floating", Comment: "Toggle floating"}, + {Mods: []string{"Super", "Shift"}, Key: "p", Action: "toggle_pinned_to_workspace", Comment: "Toggle pinned to workspace"}, + {Mods: []string{"Super"}, Key: "w", Action: "toggle_tabbing", Comment: "Toggle tabbing layout"}, + {Mods: []string{"Super"}, Key: "s", Action: "toggle_stacking", Comment: "Toggle stacking layout"}, + {Mods: []string{"Super"}, Key: "1", Action: "select_workspace_0", Comment: "Workspace 1"}, + {Mods: []string{"Super"}, Key: "2", Action: "select_workspace_1", Comment: "Workspace 2"}, + {Mods: []string{"Super"}, Key: "3", Action: "select_workspace_2", Comment: "Workspace 3"}, + {Mods: []string{"Super"}, Key: "4", Action: "select_workspace_3", Comment: "Workspace 4"}, + {Mods: []string{"Super"}, Key: "5", Action: "select_workspace_4", Comment: "Workspace 5"}, + {Mods: []string{"Super"}, Key: "6", Action: "select_workspace_5", Comment: "Workspace 6"}, + {Mods: []string{"Super"}, Key: "7", Action: "select_workspace_6", Comment: "Workspace 7"}, + {Mods: []string{"Super"}, Key: "8", Action: "select_workspace_7", Comment: "Workspace 8"}, + {Mods: []string{"Super"}, Key: "9", Action: "select_workspace_8", Comment: "Workspace 9"}, + {Mods: []string{"Super"}, Key: "0", Action: "select_workspace_9", Comment: "Workspace 10"}, + {Mods: []string{"Super", "Shift"}, Key: "1", Action: "move_to_workspace_0", Comment: "Move to workspace 1"}, + {Mods: []string{"Super", "Shift"}, Key: "2", Action: "move_to_workspace_1", Comment: "Move to workspace 2"}, + {Mods: []string{"Super", "Shift"}, Key: "3", Action: "move_to_workspace_2", Comment: "Move to workspace 3"}, + {Mods: []string{"Super", "Shift"}, Key: "4", Action: "move_to_workspace_3", Comment: "Move to workspace 4"}, + {Mods: []string{"Super", "Shift"}, Key: "5", Action: "move_to_workspace_4", Comment: "Move to workspace 5"}, + {Mods: []string{"Super", "Shift"}, Key: "6", Action: "move_to_workspace_5", Comment: "Move to workspace 6"}, + {Mods: []string{"Super", "Shift"}, Key: "7", Action: "move_to_workspace_6", Comment: "Move to workspace 7"}, + {Mods: []string{"Super", "Shift"}, Key: "8", Action: "move_to_workspace_7", Comment: "Move to workspace 8"}, + {Mods: []string{"Super", "Shift"}, Key: "9", Action: "move_to_workspace_8", Comment: "Move to workspace 9"}, + {Mods: []string{"Super", "Shift"}, Key: "0", Action: "move_to_workspace_9", Comment: "Move to workspace 10"}, +} + +func ParseMiracleConfig(configPath string) (*MiracleConfig, error) { + expanded, err := utils.ExpandPath(configPath) + if err != nil { + return nil, err + } + + info, err := os.Stat(expanded) + if err != nil { + return nil, err + } + + var configFile string + if info.IsDir() { + configFile = filepath.Join(expanded, "config.yaml") + } else { + configFile = expanded + } + + data, err := os.ReadFile(configFile) + if err != nil { + return nil, err + } + + var config MiracleConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + + if config.ActionKey == "" { + config.ActionKey = "meta" + } + + return &config, nil +} + +func resolveMiracleModifier(mod, actionKey string) string { + switch mod { + case "primary": + return resolveActionKey(actionKey) + case "alt", "alt_left", "alt_right": + return "Alt" + case "shift", "shift_left", "shift_right": + return "Shift" + case "ctrl", "ctrl_left", "ctrl_right": + return "Ctrl" + case "meta", "meta_left", "meta_right": + return "Super" + default: + return mod + } +} + +func resolveActionKey(actionKey string) string { + switch actionKey { + case "meta": + return "Super" + case "alt": + return "Alt" + case "ctrl": + return "Ctrl" + default: + return "Super" + } +} + +func miracleKeyCodeToName(keyCode string) string { + name := strings.TrimPrefix(keyCode, "KEY_") + name = strings.ToLower(name) + + switch name { + case "enter": + return "Return" + case "space": + return "Space" + case "up": + return "Up" + case "down": + return "Down" + case "left": + return "Left" + case "right": + return "Right" + case "tab": + return "Tab" + case "escape", "esc": + return "Escape" + case "delete": + return "Delete" + case "backspace": + return "BackSpace" + case "home": + return "Home" + case "end": + return "End" + case "pageup": + return "Page_Up" + case "pagedown": + return "Page_Down" + case "print": + return "Print" + case "pause": + return "Pause" + case "volumeup": + return "XF86AudioRaiseVolume" + case "volumedown": + return "XF86AudioLowerVolume" + case "mute": + return "XF86AudioMute" + case "micmute": + return "XF86AudioMicMute" + case "brightnessup": + return "XF86MonBrightnessUp" + case "brightnessdown": + return "XF86MonBrightnessDown" + case "kbdillumup": + return "XF86KbdBrightnessUp" + case "kbdillumdown": + return "XF86KbdBrightnessDown" + case "comma": + return "comma" + case "minus": + return "minus" + case "equal": + return "equal" + } + + if len(name) == 1 { + return name + } + + return name +} + +func MiracleConfigToBindings(config *MiracleConfig) []MiracleKeyBinding { + overridden := make(map[string]bool) + var bindings []MiracleKeyBinding + + for _, override := range config.DefaultActionOverrides { + mods := make([]string, 0, len(override.Modifiers)) + for _, mod := range override.Modifiers { + mods = append(mods, resolveMiracleModifier(mod, config.ActionKey)) + } + + bindings = append(bindings, MiracleKeyBinding{ + Mods: mods, + Key: miracleKeyCodeToName(override.Key), + Action: override.Name, + Comment: miracleActionDescription(override.Name), + }) + overridden[override.Name] = true + } + + for _, def := range miracleDefaultBinds { + if overridden[def.Action] { + continue + } + bindings = append(bindings, def) + } + + for _, custom := range config.CustomActions { + mods := make([]string, 0, len(custom.Modifiers)) + for _, mod := range custom.Modifiers { + mods = append(mods, resolveMiracleModifier(mod, config.ActionKey)) + } + + bindings = append(bindings, MiracleKeyBinding{ + Mods: mods, + Key: miracleKeyCodeToName(custom.Key), + Action: custom.Command, + Comment: custom.Command, + }) + } + + return bindings +} + +func miracleActionDescription(action string) string { + switch action { + case "terminal": + return "Open terminal" + case "request_vertical": + return "Layout windows vertically" + case "request_horizontal": + return "Layout windows horizontally" + case "select_up": + return "Select window above" + case "select_down": + return "Select window below" + case "select_left": + return "Select window left" + case "select_right": + return "Select window right" + case "move_up": + return "Move window up" + case "move_down": + return "Move window down" + case "move_left": + return "Move window left" + case "move_right": + return "Move window right" + case "toggle_resize": + return "Toggle resize mode" + case "fullscreen": + return "Toggle fullscreen" + case "quit_active_window": + return "Close window" + case "quit_compositor": + return "Exit compositor" + case "toggle_floating": + return "Toggle floating" + case "toggle_pinned_to_workspace": + return "Toggle pinned to workspace" + case "toggle_tabbing": + return "Toggle tabbing layout" + case "toggle_stacking": + return "Toggle stacking layout" + case "magnifier_on": + return "Enable magnifier" + case "magnifier_off": + return "Disable magnifier" + case "magnifier_increase_size": + return "Increase magnifier area" + case "magnifier_decrease_size": + return "Decrease magnifier area" + case "magnifier_increase_scale": + return "Increase magnifier scale" + case "magnifier_decrease_scale": + return "Decrease magnifier scale" + } + + if num, ok := strings.CutPrefix(action, "select_workspace_"); ok { + return "Workspace " + num + } + if num, ok := strings.CutPrefix(action, "move_to_workspace_"); ok { + return "Move to workspace " + num + } + + return action +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go new file mode 100644 index 0000000..aec20b5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go @@ -0,0 +1,650 @@ +package providers + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/sblinch/kdl-go" + "github.com/sblinch/kdl-go/document" +) + +type NiriProvider struct { + configDir string + dmsBindsIncluded bool + parsed bool +} + +func NewNiriProvider(configDir string) *NiriProvider { + if configDir == "" { + configDir = defaultNiriConfigDir() + } + return &NiriProvider{ + configDir: configDir, + } +} + +func defaultNiriConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "niri") +} + +func (n *NiriProvider) Name() string { + return "niri" +} + +func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + 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(result.Section, "", categorizedBinds, result.ConflictingConfigs) + + sheet := &keybinds.CheatSheet{ + Title: "Niri Keybinds", + Provider: n.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, 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, conflicts map[string]*NiriKeyBinding) { + currentSubcat := subcategory + if section.Name != "" { + currentSubcat = section.Name + } + + for _, kb := range section.Keybinds { + category := n.categorizeByAction(kb.Action) + bind := n.convertKeybind(&kb, currentSubcat, conflicts) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + for _, child := range section.Children { + n.convertSection(&child, currentSubcat, categorizedBinds, conflicts) + } +} + +func (n *NiriProvider) categorizeByAction(action string) string { + switch { + case action == "next-window" || action == "previous-window": + return "Alt-Tab" + case strings.Contains(action, "screenshot"): + return "Screenshot" + case action == "show-hotkey-overlay" || action == "toggle-overview": + return "Overview" + case action == "quit" || + action == "power-off-monitors" || + action == "power-on-monitors" || + action == "suspend" || + action == "do-screen-transition" || + action == "toggle-keyboard-shortcuts-inhibit" || + strings.Contains(action, "dpms"): + return "System" + case action == "spawn": + return "Execute" + case strings.Contains(action, "workspace"): + return "Workspace" + case strings.HasPrefix(action, "focus-monitor") || + strings.HasPrefix(action, "move-column-to-monitor") || + strings.HasPrefix(action, "move-window-to-monitor"): + return "Monitor" + case strings.Contains(action, "window") || + strings.Contains(action, "focus") || + strings.Contains(action, "move") || + strings.Contains(action, "swap") || + strings.Contains(action, "resize") || + strings.Contains(action, "column"): + return "Window" + default: + return "Other" + } +} + +func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, conflicts map[string]*NiriKeyBinding) keybinds.Keybind { + rawAction := n.formatRawAction(kb.Action, kb.Args) + keyStr := n.formatKey(kb) + + source := "config" + if strings.Contains(kb.Source, "dms/binds.kdl") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, + Description: kb.Description, + Action: rawAction, + Subcategory: subcategory, + Source: source, + HideOnOverlay: kb.HideOnOverlay, + CooldownMs: kb.CooldownMs, + AllowWhenLocked: kb.AllowWhenLocked, + AllowInhibiting: kb.AllowInhibiting, + Repeat: kb.Repeat, + } + + if source == "dms" && conflicts != nil { + if conflictKb, ok := conflicts[keyStr]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Description, + Action: n.formatRawAction(conflictKb.Action, conflictKb.Args), + Source: "config", + } + } + } + + return bind +} + +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, "\"", "\\\"")) + } + } + + quotedArgs := make([]string, len(args)) + for i, arg := range args { + if arg == "" { + quotedArgs[i] = `""` + } else { + quotedArgs[i] = arg + } + } + return action + " " + strings.Join(quotedArgs, " ") +} + +func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + 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), 0o755); 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) + + action := n.buildActionFromNode(child) + if action == "" { + action = n.formatRawAction(kb.Action, kb.Args) + } + + binds[keyStr] = &overrideBind{ + Key: keyStr, + Action: action, + Description: kb.Description, + Options: n.extractOptions(child), + } + } + } + + return binds, nil +} + +func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string { + if len(bindNode.Children) == 0 { + return "" + } + + actionNode := bindNode.Children[0] + actionName := actionNode.Name.String() + if actionName == "" { + return "" + } + + parts := []string{actionName} + for _, arg := range actionNode.Arguments { + val := arg.ValueString() + if val == "" { + parts = append(parts, `""`) + } else if strings.ContainsAny(val, " \t") { + parts = append(parts, `"`+strings.ReplaceAll(val, `"`, `\"`)+`"`) + } else { + parts = append(parts, val) + } + } + + if actionNode.Properties != nil { + for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} { + if val, ok := actionNode.Properties.Get(propName); ok { + parts = append(parts, propName+"="+val.String()) + } + } + } + + return strings.Join(parts, " ") +} + +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 { + if ms, err := strconv.Atoi(val.String()); err == nil { + opts["cooldown-ms"] = ms + } + } + if val, ok := node.Properties.Get("allow-when-locked"); ok { + opts["allow-when-locked"] = val.String() == "true" + } + if val, ok := node.Properties.Get("allow-inhibiting"); ok { + opts["allow-inhibiting"] = 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) 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 { + switch val := v.(type) { + case int: + node.AddProperty("cooldown-ms", val, "") + case string: + if ms, err := strconv.Atoi(val); err == nil { + node.AddProperty("cooldown-ms", ms, "") + } + } + } + if v, ok := bind.Options["allow-when-locked"]; ok && v == true { + node.AddProperty("allow-when-locked", true, "") + } + if v, ok := bind.Options["allow-inhibiting"]; ok && v == false { + node.AddProperty("allow-inhibiting", false, "") + } + } + + 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() + + parts := n.parseActionParts(action) + if len(parts) == 0 { + node.SetName(action) + return node + } + + node.SetName(parts[0]) + for _, arg := range parts[1:] { + if strings.Contains(arg, "=") { + kv := strings.SplitN(arg, "=", 2) + switch kv[1] { + case "true": + node.AddProperty(kv[0], true, "") + case "false": + node.AddProperty(kv[0], false, "") + default: + node.AddProperty(kv[0], kv[1], "") + } + continue + } + node.AddArgument(arg, "") + } + return node +} + +func (n *NiriProvider) parseActionParts(action string) []string { + var parts []string + var current strings.Builder + var inQuote, escaped, wasQuoted bool + + for _, r := range action { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '"': + wasQuoted = true + inQuote = !inQuote + case r == ' ' && !inQuote: + if current.Len() > 0 || wasQuoted { + parts = append(parts, current.String()) + current.Reset() + wasQuoted = false + } + default: + current.WriteRune(r) + } + } + if current.Len() > 0 || wasQuoted { + parts = append(parts, current.String()) + } + return parts +} + +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), 0o644) +} + +func (n *NiriProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "workspace"): + return 1 + case strings.Contains(action, "window") || strings.Contains(action, "column") || + strings.Contains(action, "focus") || strings.Contains(action, "move") || + strings.Contains(action, "swap") || strings.Contains(action, "resize"): + return 2 + case strings.HasPrefix(action, "focus-monitor") || strings.Contains(action, "monitor"): + return 3 + case strings.Contains(action, "screenshot"): + return 4 + case action == "quit" || action == "power-off-monitors" || strings.Contains(action, "dpms"): + return 5 + case strings.HasPrefix(action, "spawn"): + return 6 + default: + return 7 + } +} + +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) + } + } + + sort.Slice(regularBinds, func(i, j int) bool { + pi, pj := n.getBindSortPriority(regularBinds[i].Action), n.getBindSortPriority(regularBinds[j].Action) + if pi != pj { + return pi < pj + } + return regularBinds[i].Key < regularBinds[j].Key + }) + + sort.Slice(recentWindowsBinds, func(i, j int) bool { + return recentWindowsBinds[i].Key < recentWindowsBinds[j].Key + }) + + 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] + actionName := child.Name.String() + sb.WriteString(actionName) + forceQuote := actionName == "spawn" + for _, arg := range child.Arguments { + sb.WriteString(" ") + n.writeArg(sb, arg.ValueString(), forceQuote) + } + if child.Properties.Exist() { + sb.WriteString(" ") + sb.WriteString(strings.TrimLeft(child.Properties.String(), " ")) + } + } + sb.WriteString("; }\n") +} + +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 { + 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 +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go new file mode 100644 index 0000000..46520ff --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go @@ -0,0 +1,400 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/sblinch/kdl-go" + "github.com/sblinch/kdl-go/document" +) + +type NiriKeyBinding struct { + Mods []string + Key string + Action string + Args []string + Description string + HideOnOverlay bool + CooldownMs int + AllowWhenLocked bool + AllowInhibiting *bool + Repeat *bool + Source string +} + +type NiriSection struct { + Name string + Keybinds []NiriKeyBinding + Children []NiriSection +} + +type NiriParser struct { + configDir string + processedFiles map[string]bool + bindMap map[string]*NiriKeyBinding + bindOrder []string + currentSource string + dmsBindsIncluded bool + dmsBindsExists bool + includeCount int + dmsIncludePos int + bindsBeforeDMS int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + dmsProcessed bool + dmsBindMap map[string]*NiriKeyBinding + conflictingConfigs map[string]*NiriKeyBinding +} + +func NewNiriParser(configDir string) *NiriParser { + return &NiriParser{ + configDir: configDir, + processedFiles: make(map[string]bool), + bindMap: make(map[string]*NiriKeyBinding), + bindOrder: []string{}, + currentSource: "", + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + dmsBindMap: make(map[string]*NiriKeyBinding), + conflictingConfigs: make(map[string]*NiriKeyBinding), + } +} + +func (p *NiriParser) Parse() (*NiriSection, error) { + dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + configPath := filepath.Join(p.configDir, "config.kdl") + section, err := p.parseFile(configPath, "") + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath, section) + } + + section.Keybinds = p.finalizeBinds() + return section, nil +} + +func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSection) { + data, err := os.ReadFile(dmsBindsPath) + if err != nil { + return + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return + } + + prevSource := p.currentSource + p.currentSource = dmsBindsPath + baseDir := filepath.Dir(dmsBindsPath) + p.processNodes(doc.Nodes, section, baseDir) + p.currentSource = prevSource + p.dmsProcessed = true +} + +func (p *NiriParser) finalizeBinds() []NiriKeyBinding { + binds := make([]NiriKeyBinding, 0, len(p.bindOrder)) + for _, key := range p.bindOrder { + if kb, ok := p.bindMap[key]; ok { + binds = append(binds, *kb) + } + } + return binds +} + +func (p *NiriParser) addBind(kb *NiriKeyBinding) { + key := p.formatBindKey(kb) + isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl") + + if isDMSBind { + p.dmsBindKeys[key] = true + p.dmsBindMap[key] = kb + } else if p.dmsBindKeys[key] { + p.bindsAfterDMS++ + p.conflictingConfigs[key] = kb + p.configBindKeys[key] = true + return + } else { + p.configBindKeys[key] = true + } + + if _, exists := p.bindMap[key]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[key] = kb +} + +func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve path %s: %w", filePath, err) + } + + if p.processedFiles[absPath] { + return &NiriSection{Name: sectionName}, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", absPath, err) + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) + } + + section := &NiriSection{ + Name: sectionName, + } + + prevSource := p.currentSource + p.currentSource = absPath + baseDir := filepath.Dir(absPath) + p.processNodes(doc.Nodes, section, baseDir) + p.currentSource = prevSource + + return section, nil +} + +func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection, baseDir string) { + for _, node := range nodes { + name := node.Name.String() + + switch name { + case "include": + p.handleInclude(node, section, baseDir) + case "binds": + p.extractBinds(node, section, "") + case "recent-windows": + p.handleRecentWindows(node, section) + } + } +} + +func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) { + if len(node.Arguments) == 0 { + return + } + + includePath := strings.Trim(node.Arguments[0].String(), "\"") + isDMSInclude := includePath == "dms/binds.kdl" || strings.HasSuffix(includePath, "/dms/binds.kdl") + + p.includeCount++ + if isDMSInclude { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.bindsBeforeDMS = len(p.bindMap) + } + + fullPath := filepath.Join(baseDir, includePath) + if filepath.IsAbs(includePath) { + fullPath = includePath + } + + if isDMSInclude { + p.dmsProcessed = true + } + + includedSection, err := p.parseFile(fullPath, "") + if err != nil { + return + } + + 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 + } + + for _, child := range node.Children { + if child.Name.String() != "binds" { + continue + } + p.extractBinds(child, section, "Alt-Tab") + } +} + +func (p *NiriParser) extractBinds(node *document.Node, section *NiriSection, subcategory string) { + if node.Children == nil { + return + } + + for _, child := range node.Children { + kb := p.parseKeybindNode(child, subcategory) + if kb == nil { + continue + } + p.addBind(kb) + } +} + +func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBinding { + keyCombo := node.Name.String() + if keyCombo == "" { + return nil + } + + mods, key := p.parseKeyCombo(keyCombo) + + 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, arg.ValueString()) + } + if actionNode.Properties != nil { + for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} { + if val, ok := actionNode.Properties.Get(propName); ok { + args = append(args, propName+"="+val.String()) + } + } + } + } + + var description string + var hideOnOverlay bool + var cooldownMs int + var allowWhenLocked bool + var allowInhibiting *bool + var repeat *bool + if node.Properties != nil { + if val, ok := node.Properties.Get("hotkey-overlay-title"); ok { + switch val.ValueString() { + case "null", "": + hideOnOverlay = true + default: + description = val.ValueString() + } + } + if val, ok := node.Properties.Get("cooldown-ms"); ok { + cooldownMs, _ = strconv.Atoi(val.String()) + } + if val, ok := node.Properties.Get("allow-when-locked"); ok { + allowWhenLocked = val.String() == "true" + } + if val, ok := node.Properties.Get("allow-inhibiting"); ok { + v := val.String() == "true" + allowInhibiting = &v + } + if val, ok := node.Properties.Get("repeat"); ok { + v := val.String() == "true" + repeat = &v + } + } + + return &NiriKeyBinding{ + Mods: mods, + Key: key, + Action: action, + Args: args, + Description: description, + HideOnOverlay: hideOnOverlay, + CooldownMs: cooldownMs, + AllowWhenLocked: allowWhenLocked, + AllowInhibiting: allowInhibiting, + Repeat: repeat, + Source: p.currentSource, + } +} + +func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) { + parts := strings.Split(combo, "+") + + switch len(parts) { + case 0: + return nil, combo + case 1: + return nil, parts[0] + default: + return parts[:len(parts)-1], parts[len(parts)-1] + } +} + +type NiriParseResult struct { + Section *NiriSection + DMSBindsIncluded bool + DMSStatus *DMSBindsStatusInfo + ConflictingConfigs map[string]*NiriKeyBinding +} + +type DMSBindsStatusInfo struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *NiriParser) buildDMSStatus() *DMSBindsStatusInfo { + status := &DMSBindsStatusInfo{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.kdl does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.kdl is not included in config.kdl" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func ParseNiriKeys(configDir string) (*NiriParseResult, error) { + parser := NewNiriParser(configDir) + section, err := parser.Parse() + if err != nil { + return nil, err + } + return &NiriParseResult{ + Section: section, + DMSBindsIncluded: parser.HasDMSBindsIncluded(), + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go new file mode 100644 index 0000000..e21fde5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go @@ -0,0 +1,630 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNiriParseKeyCombo(t *testing.T) { + tests := []struct { + combo string + expectedMods []string + expectedKey string + }{ + {"Mod+Q", []string{"Mod"}, "Q"}, + {"Mod+Shift+F", []string{"Mod", "Shift"}, "F"}, + {"Ctrl+Alt+Delete", []string{"Ctrl", "Alt"}, "Delete"}, + {"Print", nil, "Print"}, + {"XF86AudioMute", nil, "XF86AudioMute"}, + {"Super+Tab", []string{"Super"}, "Tab"}, + {"Mod+Shift+Ctrl+H", []string{"Mod", "Shift", "Ctrl"}, "H"}, + } + + parser := NewNiriParser("") + for _, tt := range tests { + t.Run(tt.combo, func(t *testing.T) { + mods, key := parser.parseKeyCombo(tt.combo) + + if len(mods) != len(tt.expectedMods) { + t.Errorf("Mods length = %d, want %d", len(mods), len(tt.expectedMods)) + } else { + for i := range mods { + if mods[i] != tt.expectedMods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, mods[i], tt.expectedMods[i]) + } + } + } + + if key != tt.expectedKey { + t.Errorf("Key = %q, want %q", key, tt.expectedKey) + } + }) + } +} + +func TestNiriParseBasicBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Q { close-window; } + Mod+F { fullscreen-window; } + Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); 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)) + } + + foundClose := false + foundFullscreen := false + foundTerminal := false + + for _, kb := range result.Section.Keybinds { + switch kb.Action { + case "close-window": + foundClose = true + if kb.Key != "Q" || len(kb.Mods) != 1 || kb.Mods[0] != "Mod" { + t.Errorf("close-window keybind mismatch: %+v", kb) + } + case "fullscreen-window": + foundFullscreen = true + case "spawn": + foundTerminal = true + if kb.Description != "Open Terminal" { + t.Errorf("spawn description = %q, want %q", kb.Description, "Open Terminal") + } + if len(kb.Args) != 1 || kb.Args[0] != "kitty" { + t.Errorf("spawn args = %v, want [kitty]", kb.Args) + } + } + } + + if !foundClose { + t.Error("close-window keybind not found") + } + if !foundFullscreen { + t.Error("fullscreen-window keybind not found") + } + if !foundTerminal { + t.Error("spawn keybind not found") + } +} + +func TestNiriParseRecentWindows(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `recent-windows { + binds { + Alt+Tab { next-window scope="output"; } + Alt+Shift+Tab { previous-window scope="output"; } + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); 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) != 2 { + t.Errorf("Expected 2 keybinds from recent-windows, got %d", len(result.Section.Keybinds)) + } + + foundNext := false + foundPrev := false + + for _, kb := range result.Section.Keybinds { + switch kb.Action { + case "next-window": + foundNext = true + case "previous-window": + foundPrev = true + } + } + + if !foundNext { + t.Error("next-window keybind not found") + } + if !foundPrev { + t.Error("previous-window keybind not found") + } +} + +func TestNiriParseInclude(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + mainConfig := filepath.Join(tmpDir, "config.kdl") + includeConfig := filepath.Join(subDir, "binds.kdl") + + mainContent := `binds { + Mod+Q { close-window; } +} +include "dms/binds.kdl" +` + includeContent := `binds { + Mod+T hotkey-overlay-title="Terminal" { spawn "kitty"; } +} +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (1 main + 1 include), got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseIncludeOverride(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + mainConfig := filepath.Join(tmpDir, "config.kdl") + includeConfig := filepath.Join(subDir, "binds.kdl") + + mainContent := `binds { + Mod+T hotkey-overlay-title="Main Terminal" { spawn "alacritty"; } +} +include "dms/binds.kdl" +` + includeContent := `binds { + Mod+T hotkey-overlay-title="Override Terminal" { spawn "kitty"; } +} +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 1 { + t.Errorf("Expected 1 keybind (later overrides earlier), got %d", len(result.Section.Keybinds)) + } + + 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) + } + if len(kb.Args) != 1 || kb.Args[0] != "kitty" { + t.Errorf("Expected args [kitty] (from include), got %v", kb.Args) + } + } +} + +func TestNiriParseCircularInclude(t *testing.T) { + tmpDir := t.TempDir() + + mainConfig := filepath.Join(tmpDir, "config.kdl") + otherConfig := filepath.Join(tmpDir, "other.kdl") + + mainContent := `binds { + Mod+Q { close-window; } +} +include "other.kdl" +` + otherContent := `binds { + Mod+T { spawn "kitty"; } +} +include "config.kdl" +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(otherConfig, []byte(otherContent), 0o644); err != nil { + t.Fatalf("Failed to write other config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed (should handle circular includes): %v", err) + } + + if len(result.Section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (circular include handled), got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseMissingInclude(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Q { close-window; } +} +include "nonexistent/file.kdl" +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed (should skip missing include): %v", err) + } + + if len(result.Section.Keybinds) != 1 { + t.Errorf("Expected 1 keybind (missing include skipped), got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseNoBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `cursor { + xcursor-theme "Bibata" + xcursor-size 24 +} + +input { + keyboard { + numlock + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); 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) != 0 { + t.Errorf("Expected 0 keybinds, got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseNiriKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestNiriBindOverrideBehavior(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+T hotkey-overlay-title="First" { spawn "first"; } + Mod+Q { close-window; } + Mod+T hotkey-overlay-title="Second" { spawn "second"; } + Mod+F { fullscreen-window; } + Mod+T hotkey-overlay-title="Third" { spawn "third"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); 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.Fatalf("Expected 3 unique keybinds, got %d", len(result.Section.Keybinds)) + } + + var modT *NiriKeyBinding + 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 + } + } + + if modT == nil { + t.Fatal("Mod+T keybind not found") + } + + if modT.Description != "Third" { + t.Errorf("Mod+T description = %q, want 'Third' (last definition wins)", modT.Description) + } + + if len(modT.Args) != 1 || modT.Args[0] != "third" { + t.Errorf("Mod+T args = %v, want [third] (last definition wins)", modT.Args) + } +} + +func TestNiriBindOverrideWithIncludes(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "custom") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + mainConfig := filepath.Join(tmpDir, "config.kdl") + includeConfig := filepath.Join(subDir, "overrides.kdl") + + mainContent := `binds { + Mod+1 { focus-workspace 1; } + Mod+2 { focus-workspace 2; } + Mod+T hotkey-overlay-title="Default Terminal" { spawn "xterm"; } +} +include "custom/overrides.kdl" +binds { + Mod+3 { focus-workspace 3; } +} +` + includeContent := `binds { + Mod+T hotkey-overlay-title="Custom Terminal" { spawn "kitty"; } + Mod+2 { focus-workspace 22; } +} +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { + t.Fatalf("Failed to write include 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 unique keybinds, got %d", len(result.Section.Keybinds)) + } + + bindMap := make(map[string]*NiriKeyBinding) + for i := range result.Section.Keybinds { + kb := &result.Section.Keybinds[i] + key := "" + for _, m := range kb.Mods { + key += m + "+" + } + key += kb.Key + bindMap[key] = kb + } + + if kb, ok := bindMap["Mod+T"]; ok { + if kb.Description != "Custom Terminal" { + t.Errorf("Mod+T should be overridden by include, got description %q", kb.Description) + } + } else { + t.Error("Mod+T not found") + } + + if kb, ok := bindMap["Mod+2"]; ok { + if len(kb.Args) != 1 || kb.Args[0] != "22" { + t.Errorf("Mod+2 should be overridden by include with workspace 22, got args %v", kb.Args) + } + } else { + t.Error("Mod+2 not found") + } + + if _, ok := bindMap["Mod+1"]; !ok { + t.Error("Mod+1 should exist (not overridden)") + } + + if _, ok := bindMap["Mod+3"]; !ok { + t.Error("Mod+3 should exist (added after include)") + } +} + +func TestNiriParseMultipleArgs(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Space hotkey-overlay-title="Application Launcher" { + spawn "dms" "ipc" "call" "spotlight" "toggle"; + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); 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) != 1 { + t.Fatalf("Expected 1 keybind, got %d", len(result.Section.Keybinds)) + } + + kb := result.Section.Keybinds[0] + if len(kb.Args) != 5 { + t.Errorf("Expected 5 args, got %d: %v", len(kb.Args), kb.Args) + } + + expectedArgs := []string{"dms", "ipc", "call", "spotlight", "toggle"} + for i, arg := range expectedArgs { + if i < len(kb.Args) && kb.Args[i] != arg { + t.Errorf("Args[%d] = %q, want %q", i, kb.Args[i], arg) + } + } +} + +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), 0o644); 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), 0o644); 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), 0o644); 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) != 2 { + t.Errorf("move-column-to-workspace should have 2 args (index + focus), got %d", len(kb.Args)) + } + hasIndex := false + hasFocus := false + for _, arg := range kb.Args { + if arg == "1" || arg == "2" { + hasIndex = true + } + if arg == "focus=false" { + hasFocus = true + } + } + if !hasIndex { + t.Errorf("move-column-to-workspace missing index arg") + } + if !hasFocus { + t.Errorf("move-column-to-workspace missing focus=false arg") + } + case "next-window": + if kb.Key != "Tab" { + t.Errorf("next-window key = %q, want 'Tab'", kb.Key) + } + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go new file mode 100644 index 0000000..b04c5b2 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go @@ -0,0 +1,661 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNiriProviderName(t *testing.T) { + provider := NewNiriProvider("") + if provider.Name() != "niri" { + t.Errorf("Name() = %q, want %q", provider.Name(), "niri") + } +} + +func TestNiriProviderGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Q { close-window; } + Mod+F { fullscreen-window; } + Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } + Mod+1 { focus-workspace 1; } + Mod+Shift+1 { move-column-to-workspace 1; } + Print { screenshot; } + Mod+Shift+E { quit; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewNiriProvider(tmpDir) + cheatSheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if cheatSheet.Title != "Niri Keybinds" { + t.Errorf("Title = %q, want %q", cheatSheet.Title, "Niri Keybinds") + } + + if cheatSheet.Provider != "niri" { + t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri") + } + + windowBinds := cheatSheet.Binds["Window"] + if len(windowBinds) < 2 { + t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds)) + } + + execBinds := cheatSheet.Binds["Execute"] + if len(execBinds) < 1 { + t.Errorf("Expected at least 1 Execute bind, got %d", len(execBinds)) + } + + workspaceBinds := cheatSheet.Binds["Workspace"] + if len(workspaceBinds) < 2 { + t.Errorf("Expected at least 2 Workspace binds, got %d", len(workspaceBinds)) + } + + screenshotBinds := cheatSheet.Binds["Screenshot"] + if len(screenshotBinds) < 1 { + t.Errorf("Expected at least 1 Screenshot bind, got %d", len(screenshotBinds)) + } + + systemBinds := cheatSheet.Binds["System"] + if len(systemBinds) < 1 { + t.Errorf("Expected at least 1 System bind, got %d", len(systemBinds)) + } +} + +func TestNiriCategorizeByAction(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + action string + expected string + }{ + {"focus-workspace", "Workspace"}, + {"focus-workspace-up", "Workspace"}, + {"move-column-to-workspace", "Workspace"}, + {"focus-monitor-left", "Monitor"}, + {"move-column-to-monitor-right", "Monitor"}, + {"close-window", "Window"}, + {"fullscreen-window", "Window"}, + {"maximize-column", "Window"}, + {"toggle-window-floating", "Window"}, + {"focus-column-left", "Window"}, + {"move-column-right", "Window"}, + {"spawn", "Execute"}, + {"quit", "System"}, + {"power-off-monitors", "System"}, + {"screenshot", "Screenshot"}, + {"screenshot-window", "Screenshot"}, + {"toggle-overview", "Overview"}, + {"show-hotkey-overlay", "Overview"}, + {"next-window", "Alt-Tab"}, + {"previous-window", "Alt-Tab"}, + {"unknown-action", "Other"}, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + result := provider.categorizeByAction(tt.action) + if result != tt.expected { + t.Errorf("categorizeByAction(%q) = %q, want %q", tt.action, result, tt.expected) + } + }) + } +} + +func TestNiriFormatRawAction(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + action string + args []string + expected string + }{ + {"spawn", []string{"kitty"}, "spawn kitty"}, + {"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"}, + {"spawn", []string{"dms", "ipc", "call", "brightness", "increment", "5", ""}, `spawn dms ipc call brightness increment 5 ""`}, + {"spawn", []string{"dms", "ipc", "call", "dash", "toggle", ""}, `spawn dms ipc call dash toggle ""`}, + {"close-window", nil, "close-window"}, + {"fullscreen-window", nil, "fullscreen-window"}, + {"focus-workspace", []string{"1"}, "focus-workspace 1"}, + {"move-column-to-workspace", []string{"5"}, "move-column-to-workspace 5"}, + {"set-column-width", []string{"+10%"}, "set-column-width +10%"}, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + result := provider.formatRawAction(tt.action, tt.args) + if result != tt.expected { + t.Errorf("formatRawAction(%q, %v) = %q, want %q", tt.action, tt.args, result, tt.expected) + } + }) + } +} + +func TestNiriFormatKey(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + mods []string + key string + expected string + }{ + {[]string{"Mod"}, "Q", "Mod+Q"}, + {[]string{"Mod", "Shift"}, "F", "Mod+Shift+F"}, + {[]string{"Ctrl", "Alt"}, "Delete", "Ctrl+Alt+Delete"}, + {nil, "Print", "Print"}, + {[]string{}, "XF86AudioMute", "XF86AudioMute"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + kb := &NiriKeyBinding{ + Mods: tt.mods, + Key: tt.key, + } + result := provider.formatKey(kb) + if result != tt.expected { + t.Errorf("formatKey(%v) = %q, want %q", kb, result, tt.expected) + } + }) + } +} + +func TestNiriDefaultConfigDir(t *testing.T) { + originalXDG := os.Getenv("XDG_CONFIG_HOME") + defer os.Setenv("XDG_CONFIG_HOME", originalXDG) + + os.Setenv("XDG_CONFIG_HOME", "/custom/config") + dir := defaultNiriConfigDir() + if dir != "/custom/config/niri" { + t.Errorf("With XDG_CONFIG_HOME set, got %q, want %q", dir, "/custom/config/niri") + } + + os.Unsetenv("XDG_CONFIG_HOME") + dir = defaultNiriConfigDir() + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".config", "niri") + if dir != expected { + t.Errorf("Without XDG_CONFIG_HOME, got %q, want %q", dir, expected) + } +} + +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), 0o644); 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 TestNiriEmptyArgsPreservation(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "XF86MonBrightnessUp": { + Key: "XF86MonBrightnessUp", + Action: `spawn dms ipc call brightness increment 5 ""`, + Description: "Brightness Up", + }, + "XF86MonBrightnessDown": { + Key: "XF86MonBrightnessDown", + Action: `spawn dms ipc call brightness decrement 5 ""`, + Description: "Brightness Down", + }, + "Super+Alt+Page_Up": { + Key: "Super+Alt+Page_Up", + Action: `spawn dms ipc call dash toggle ""`, + Description: "Dashboard Toggle", + }, + } + + content := provider.generateBindsContent(binds) + + tmpDir := t.TempDir() + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + t.Fatalf("Failed to create dms directory: %v", err) + } + + bindsFile := filepath.Join(dmsDir, "binds.kdl") + if err := os.WriteFile(bindsFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write binds file: %v", err) + } + + testProvider := NewNiriProvider(tmpDir) + loadedBinds, err := testProvider.loadOverrideBinds() + if err != nil { + t.Fatalf("Failed to load binds: %v\nContent was:\n%s", err, content) + } + + for key, expected := range binds { + loaded, ok := loadedBinds[key] + if !ok { + t.Errorf("Missing bind for key %s", key) + continue + } + if loaded.Action != expected.Action { + t.Errorf("Action mismatch for %s:\n got: %q\n want: %q", key, loaded.Action, expected.Action) + } + } +} + +func TestNiriProviderWithRealWorldConfig(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Shift+Ctrl+D { debug-toggle-damage; } + Super+D { spawn "niri" "msg" "action" "toggle-overview"; } + Super+Tab repeat=false { toggle-overview; } + Mod+Shift+Slash { show-hotkey-overlay; } + + Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } + Mod+Space hotkey-overlay-title="Application Launcher" { + spawn "dms" "ipc" "call" "spotlight" "toggle"; + } + + XF86AudioRaiseVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "increment" "3"; + } + XF86AudioLowerVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "decrement" "3"; + } + + Mod+Q repeat=false { close-window; } + Mod+F { maximize-column; } + Mod+Shift+F { fullscreen-window; } + + Mod+Left { focus-column-left; } + Mod+Down { focus-window-down; } + Mod+Up { focus-window-up; } + Mod+Right { focus-column-right; } + + Mod+1 { focus-workspace 1; } + Mod+2 { focus-workspace 2; } + Mod+Shift+1 { move-column-to-workspace 1; } + Mod+Shift+2 { move-column-to-workspace 2; } + + Print { screenshot; } + Ctrl+Print { screenshot-screen; } + Alt+Print { screenshot-window; } + + Mod+Shift+E { quit; } +} + +recent-windows { + binds { + Alt+Tab { next-window scope="output"; } + Alt+Shift+Tab { previous-window scope="output"; } + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewNiriProvider(tmpDir) + cheatSheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + totalBinds := 0 + for _, binds := range cheatSheet.Binds { + totalBinds += len(binds) + } + + if totalBinds < 20 { + t.Errorf("Expected at least 20 keybinds, got %d", totalBinds) + } + + if len(cheatSheet.Binds["Alt-Tab"]) < 2 { + 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), 0o644); 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/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go new file mode 100644 index 0000000..93ceb73 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go @@ -0,0 +1,150 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" +) + +type SwayProvider struct { + configPath string + isScroll bool +} + +func NewSwayProvider(configPath string) *SwayProvider { + isScroll := false + _, scrollEnvSet := os.LookupEnv("SCROLLSOCK") + + if configPath == "" { + configDir, err := os.UserConfigDir() + if err != nil { + configDir = "" + } + if scrollEnvSet { + if configDir != "" { + configPath = filepath.Join(configDir, "scroll") + } + isScroll = true + } else { + if configDir != "" { + configPath = filepath.Join(configDir, "sway") + } + } + } else { + isScroll = strings.Contains(configPath, "scroll") + } + + return &SwayProvider{ + configPath: configPath, + isScroll: isScroll, + } +} + +func (s *SwayProvider) Name() string { + if s == nil { + if os.Getenv("SCROLLSOCK") != "" { + return "scroll" + } + return "sway" + } + + if s.isScroll { + return "scroll" + } + return "sway" +} + +func (s *SwayProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + section, err := ParseSwayKeys(s.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse sway config: %w", err) + } + + categorizedBinds := make(map[string][]keybinds.Keybind) + s.convertSection(section, "", categorizedBinds) + + cheatSheetTitle := "Sway Keybinds" + if s != nil && s.isScroll { + cheatSheetTitle = "Scroll Keybinds" + } + + return &keybinds.CheatSheet{ + Title: cheatSheetTitle, + Provider: s.Name(), + Binds: categorizedBinds, + }, nil +} + +func (s *SwayProvider) convertSection(section *SwaySection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) { + currentSubcat := subcategory + if section.Name != "" { + currentSubcat = section.Name + } + + for _, kb := range section.Keybinds { + category := s.categorizeByCommand(kb.Command) + bind := s.convertKeybind(&kb, currentSubcat) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + for _, child := range section.Children { + s.convertSection(&child, currentSubcat, categorizedBinds) + } +} + +func (s *SwayProvider) categorizeByCommand(command string) string { + command = strings.ToLower(command) + + switch { + case strings.Contains(command, "scratchpad"): + return "Scratchpad" + case strings.Contains(command, "workspace") && strings.Contains(command, "output"): + return "Monitor" + case strings.Contains(command, "workspace"): + return "Workspace" + case strings.Contains(command, "output"): + return "Monitor" + case strings.Contains(command, "layout"): + return "Layout" + case command == "kill" || + command == "fullscreen" || strings.Contains(command, "fullscreen") || + command == "floating toggle" || strings.Contains(command, "floating") || + strings.Contains(command, "focus") || + strings.Contains(command, "move") || + strings.Contains(command, "resize") || + strings.Contains(command, "split"): + return "Window" + case strings.HasPrefix(command, "exec"): + return "Execute" + case command == "exit" || command == "reload": + return "System" + default: + return "Other" + } +} + +func (s *SwayProvider) convertKeybind(kb *SwayKeyBinding, subcategory string) keybinds.Keybind { + key := s.formatKey(kb) + desc := kb.Comment + + if desc == "" { + desc = kb.Command + } + + return keybinds.Keybind{ + Key: key, + Description: desc, + Action: kb.Command, + Subcategory: subcategory, + } +} + +func (s *SwayProvider) formatKey(kb *SwayKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go new file mode 100644 index 0000000..647c9f7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go @@ -0,0 +1,364 @@ +package providers + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +const ( + SwayTitleRegex = "#+!" + SwayHideComment = "[hidden]" +) + +var SwayModSeparators = []rune{'+', ' '} + +type SwayKeyBinding struct { + Mods []string `json:"mods"` + Key string `json:"key"` + Command string `json:"command"` + Comment string `json:"comment"` +} + +type SwaySection struct { + Children []SwaySection `json:"children"` + Keybinds []SwayKeyBinding `json:"keybinds"` + Name string `json:"name"` +} + +type SwayParser struct { + contentLines []string + readingLine int + variables map[string]string +} + +func NewSwayParser() *SwayParser { + return &SwayParser{ + contentLines: []string{}, + readingLine: 0, + variables: make(map[string]string), + } +} + +func (p *SwayParser) ReadContent(path string) error { + expandedPath, err := utils.ExpandPath(path) + if err != nil { + return err + } + + info, err := os.Stat(expandedPath) + if err != nil { + return err + } + + var files []string + if info.IsDir() { + mainConfig := filepath.Join(expandedPath, "config") + if fileInfo, err := os.Stat(mainConfig); err == nil && fileInfo.Mode().IsRegular() { + files = []string{mainConfig} + } else { + return os.ErrNotExist + } + } else { + files = []string{expandedPath} + } + + var combinedContent []string + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + return err + } + combinedContent = append(combinedContent, string(data)) + } + + if len(combinedContent) == 0 { + return os.ErrNotExist + } + + fullContent := strings.Join(combinedContent, "\n") + p.contentLines = strings.Split(fullContent, "\n") + p.parseVariables() + return nil +} + +func (p *SwayParser) parseVariables() { + setRegex := regexp.MustCompile(`^\s*set\s+\$(\w+)\s+(.+)$`) + for _, line := range p.contentLines { + matches := setRegex.FindStringSubmatch(line) + if len(matches) == 3 { + varName := matches[1] + varValue := strings.TrimSpace(matches[2]) + p.variables[varName] = varValue + } + } +} + +func (p *SwayParser) expandVariables(text string) string { + result := text + for varName, varValue := range p.variables { + result = strings.ReplaceAll(result, "$"+varName, varValue) + } + return result +} + +func swayAutogenerateComment(command string) string { + command = strings.TrimSpace(command) + + if strings.HasPrefix(command, "exec ") { + cmdPart := strings.TrimPrefix(command, "exec ") + cmdPart = strings.TrimPrefix(cmdPart, "--no-startup-id ") + return cmdPart + } + + switch { + case command == "kill": + return "Close window" + case command == "exit": + return "Exit Sway" + case command == "reload": + return "Reload configuration" + case strings.HasPrefix(command, "fullscreen"): + return "Toggle fullscreen" + case strings.HasPrefix(command, "floating toggle"): + return "Float/unfloat window" + case strings.HasPrefix(command, "focus mode_toggle"): + return "Toggle focus mode" + case strings.HasPrefix(command, "focus parent"): + return "Focus parent container" + case strings.HasPrefix(command, "focus left"): + return "Focus left" + case strings.HasPrefix(command, "focus right"): + return "Focus right" + case strings.HasPrefix(command, "focus up"): + return "Focus up" + case strings.HasPrefix(command, "focus down"): + return "Focus down" + case strings.HasPrefix(command, "focus output"): + return "Focus monitor" + case strings.HasPrefix(command, "move left"): + return "Move window left" + case strings.HasPrefix(command, "move right"): + return "Move window right" + case strings.HasPrefix(command, "move up"): + return "Move window up" + case strings.HasPrefix(command, "move down"): + return "Move window down" + case strings.HasPrefix(command, "move container to workspace"): + if strings.Contains(command, "prev") { + return "Move to previous workspace" + } + if strings.Contains(command, "next") { + return "Move to next workspace" + } + parts := strings.Fields(command) + if len(parts) > 4 { + return "Move to workspace " + parts[len(parts)-1] + } + return "Move to workspace" + case strings.HasPrefix(command, "move workspace to output"): + return "Move workspace to monitor" + case strings.HasPrefix(command, "workspace"): + if strings.Contains(command, "prev") { + return "Previous workspace" + } + if strings.Contains(command, "next") { + return "Next workspace" + } + parts := strings.Fields(command) + if len(parts) > 1 { + wsNum := parts[len(parts)-1] + return "Workspace " + wsNum + } + return "Switch workspace" + case strings.HasPrefix(command, "layout"): + parts := strings.Fields(command) + if len(parts) > 1 { + return "Layout " + parts[1] + } + return "Change layout" + case strings.HasPrefix(command, "split"): + if strings.Contains(command, "h") { + return "Split horizontal" + } + if strings.Contains(command, "v") { + return "Split vertical" + } + return "Split container" + case strings.HasPrefix(command, "resize"): + return "Resize window" + case strings.Contains(command, "scratchpad"): + return "Toggle scratchpad" + default: + return command + } +} + +func (p *SwayParser) getKeybindAtLine(lineNumber int) *SwayKeyBinding { + if lineNumber >= len(p.contentLines) { + return nil + } + + line := p.contentLines[lineNumber] + + bindMatch := regexp.MustCompile(`^\s*(bindsym|bindcode)\s+(.+)$`) + matches := bindMatch.FindStringSubmatch(line) + if len(matches) < 3 { + return nil + } + + content := matches[2] + + parts := strings.SplitN(content, "#", 2) + keys := strings.TrimSpace(parts[0]) + + var comment string + if len(parts) > 1 { + comment = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(comment, SwayHideComment) { + return nil + } + + flags := "" + if strings.HasPrefix(keys, "--") { + spaceIdx := strings.Index(keys, " ") + if spaceIdx > 0 { + flags = keys[:spaceIdx] + keys = strings.TrimSpace(keys[spaceIdx+1:]) + } + } + + keyParts := strings.Fields(keys) + if len(keyParts) < 2 { + return nil + } + + keyCombo := keyParts[0] + keyCombo = p.expandVariables(keyCombo) + command := strings.Join(keyParts[1:], " ") + command = p.expandVariables(command) + + var modList []string + var key string + + modstring := keyCombo + string(SwayModSeparators[0]) + pos := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range SwayModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-pos > 0 { + part := modstring[pos:index] + if swayIsMod(part) { + modList = append(modList, part) + } else { + key = part + } + } + pos = index + 1 + } + } + + if comment == "" { + comment = swayAutogenerateComment(command) + } + + _ = flags + + return &SwayKeyBinding{ + Mods: modList, + Key: key, + Command: command, + Comment: comment, + } +} + +func swayIsMod(s string) bool { + s = strings.ToLower(s) + if s == "mod1" || s == "mod2" || s == "mod3" || s == "mod4" || s == "mod5" || + s == "shift" || s == "control" || s == "ctrl" || s == "alt" || s == "super" || + strings.HasPrefix(s, "$") { + return true + } + + isNumeric := true + for _, c := range s { + if c < '0' || c > '9' { + isNumeric = false + break + } + } + if isNumeric && len(s) >= 2 { + return true + } + return false +} + +func (p *SwayParser) getBindsRecursive(currentContent *SwaySection, scope int) *SwaySection { + titleRegex := regexp.MustCompile(SwayTitleRegex) + + for p.readingLine < len(p.contentLines) { + line := p.contentLines[p.readingLine] + + loc := titleRegex.FindStringIndex(line) + if loc != nil && loc[0] == 0 { + headingScope := strings.Index(line, "!") + + if headingScope <= scope { + p.readingLine-- + return currentContent + } + + sectionName := strings.TrimSpace(line[headingScope+1:]) + p.readingLine++ + + childSection := &SwaySection{ + Children: []SwaySection{}, + Keybinds: []SwayKeyBinding{}, + Name: sectionName, + } + result := p.getBindsRecursive(childSection, headingScope) + currentContent.Children = append(currentContent.Children, *result) + + } else if line == "" || (!strings.Contains(line, "bindsym") && !strings.Contains(line, "bindcode")) { + + } else { + keybind := p.getKeybindAtLine(p.readingLine) + if keybind != nil { + currentContent.Keybinds = append(currentContent.Keybinds, *keybind) + } + } + + p.readingLine++ + } + + return currentContent +} + +func (p *SwayParser) ParseKeys() *SwaySection { + p.readingLine = 0 + rootSection := &SwaySection{ + Children: []SwaySection{}, + Keybinds: []SwayKeyBinding{}, + Name: "", + } + return p.getBindsRecursive(rootSection, 0) +} + +func ParseSwayKeys(path string) (*SwaySection, error) { + parser := NewSwayParser() + if err := parser.ReadContent(path); err != nil { + return nil, err + } + return parser.ParseKeys(), nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go new file mode 100644 index 0000000..6bb5ecf --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go @@ -0,0 +1,471 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSwayAutogenerateComment(t *testing.T) { + tests := []struct { + command string + expected string + }{ + {"exec kitty", "kitty"}, + {"exec --no-startup-id firefox", "firefox"}, + {"kill", "Close window"}, + {"exit", "Exit Sway"}, + {"reload", "Reload configuration"}, + {"fullscreen toggle", "Toggle fullscreen"}, + {"floating toggle", "Float/unfloat window"}, + {"focus mode_toggle", "Toggle focus mode"}, + {"focus parent", "Focus parent container"}, + {"focus left", "Focus left"}, + {"focus right", "Focus right"}, + {"focus up", "Focus up"}, + {"focus down", "Focus down"}, + {"focus output left", "Focus monitor"}, + {"move left", "Move window left"}, + {"move right", "Move window right"}, + {"move up", "Move window up"}, + {"move down", "Move window down"}, + {"move container to workspace number 1", "Move to workspace 1"}, + {"move container to workspace prev", "Move to previous workspace"}, + {"move container to workspace next", "Move to next workspace"}, + {"move workspace to output left", "Move workspace to monitor"}, + {"workspace number 1", "Workspace 1"}, + {"workspace prev", "Previous workspace"}, + {"workspace next", "Next workspace"}, + {"layout tabbed", "Layout tabbed"}, + {"layout stacking", "Layout stacking"}, + {"splith", "Split horizontal"}, + {"splitv", "Split vertical"}, + {"resize grow width 10 ppt", "Resize window"}, + {"move scratchpad", "Toggle scratchpad"}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := swayAutogenerateComment(tt.command) + if result != tt.expected { + t.Errorf("swayAutogenerateComment(%q) = %q, want %q", + tt.command, result, tt.expected) + } + }) + } +} + +func TestSwayGetKeybindAtLine(t *testing.T) { + tests := []struct { + name string + line string + expected *SwayKeyBinding + }{ + { + name: "basic_keybind", + line: "bindsym Mod4+q kill", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "q", + Command: "kill", + Comment: "Close window", + }, + }, + { + name: "keybind_with_exec", + line: "bindsym Mod4+t exec kitty", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "t", + Command: "exec kitty", + Comment: "kitty", + }, + }, + { + name: "keybind_with_comment", + line: "bindsym Mod4+Space exec dms ipc call spotlight toggle # Open launcher", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "Space", + Command: "exec dms ipc call spotlight toggle", + Comment: "Open launcher", + }, + }, + { + name: "keybind_hidden", + line: "bindsym Mod4+h exec secret # [hidden]", + expected: nil, + }, + { + name: "keybind_multiple_mods", + line: "bindsym Mod4+Shift+e exit", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4", "Shift"}, + Key: "e", + Command: "exit", + Comment: "Exit Sway", + }, + }, + { + name: "keybind_no_mods", + line: "bindsym Print exec grim screenshot.png", + expected: &SwayKeyBinding{ + Mods: []string{}, + Key: "Print", + Command: "exec grim screenshot.png", + Comment: "grim screenshot.png", + }, + }, + { + name: "keybind_with_flags", + line: "bindsym --release Mod4+x exec notify-send released", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "x", + Command: "exec notify-send released", + Comment: "notify-send released", + }, + }, + { + name: "keybind_focus_direction", + line: "bindsym Mod4+Left focus left", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "Left", + Command: "focus left", + Comment: "Focus left", + }, + }, + { + name: "keybind_workspace", + line: "bindsym Mod4+1 workspace number 1", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "1", + Command: "workspace number 1", + Comment: "Workspace 1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewSwayParser() + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if tt.expected == nil { + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + return + } + + if result == nil { + t.Errorf("expected %+v, got nil", tt.expected) + return + } + + if result.Key != tt.expected.Key { + t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key) + } + if result.Command != tt.expected.Command { + t.Errorf("Command = %q, want %q", result.Command, tt.expected.Command) + } + if result.Comment != tt.expected.Comment { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment) + } + if len(result.Mods) != len(tt.expected.Mods) { + t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods)) + } else { + for i := range result.Mods { + if result.Mods[i] != tt.expected.Mods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i]) + } + } + } + }) + } +} + +func TestSwayVariableExpansion(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 +set $term kitty +set $menu rofi + +bindsym $mod+t exec $term +bindsym $mod+d exec $menu +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(configFile) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds, got %d", len(section.Keybinds)) + } + + if len(section.Keybinds) > 0 { + if section.Keybinds[0].Mods[0] != "Mod4" { + t.Errorf("Expected Mod4, got %q", section.Keybinds[0].Mods[0]) + } + if section.Keybinds[0].Command != "exec kitty" { + t.Errorf("Expected 'exec kitty', got %q", section.Keybinds[0].Command) + } + } + + if len(section.Keybinds) > 1 { + if section.Keybinds[1].Command != "exec rofi" { + t.Errorf("Expected 'exec rofi', got %q", section.Keybinds[1].Command) + } + } +} + +func TestSwayParseKeysWithSections(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 + +##! Window Management +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle + +###! Focus +bindsym $mod+Left focus left +bindsym $mod+Right focus right + +##! Applications +bindsym $mod+t exec kitty # Terminal +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(tmpDir) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Children) != 2 { + t.Errorf("Expected 2 top-level sections, got %d", len(section.Children)) + } + + if len(section.Children) >= 1 { + windowMgmt := section.Children[0] + if windowMgmt.Name != "Window Management" { + t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management") + } + if len(windowMgmt.Keybinds) != 2 { + t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds)) + } + + if len(windowMgmt.Children) != 1 { + t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children)) + } else { + focus := windowMgmt.Children[0] + if focus.Name != "Focus" { + t.Errorf("Focus section name = %q, want %q", focus.Name, "Focus") + } + if len(focus.Keybinds) != 2 { + t.Errorf("Focus keybinds = %d, want 2", len(focus.Keybinds)) + } + } + } + + if len(section.Children) >= 2 { + apps := section.Children[1] + if apps.Name != "Applications" { + t.Errorf("Second section name = %q, want %q", apps.Name, "Applications") + } + if len(apps.Keybinds) != 1 { + t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds)) + } + if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" { + t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal") + } + } +} + +func TestSwayReadContentErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + { + name: "empty_directory", + path: t.TempDir(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseSwayKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestSwayReadContentWithTildeExpansion(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tmpSubdir := filepath.Join(homeDir, ".config", "test-sway-"+t.Name()) + if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { + t.Skip("Cannot create test directory in home") + } + defer os.RemoveAll(tmpSubdir) + + configFile := filepath.Join(tmpSubdir, "config") + if err := os.WriteFile(configFile, []byte("bindsym Mod4+q kill\n"), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + relPath, err := filepath.Rel(homeDir, tmpSubdir) + if err != nil { + t.Skip("Cannot create relative path") + } + + parser := NewSwayParser() + tildePathMatch := "~/" + relPath + err = parser.ReadContent(tildePathMatch) + + if err != nil { + t.Errorf("ReadContent with tilde path failed: %v", err) + } +} + +func TestSwayEmptyAndCommentLines(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := ` +# This is a comment +bindsym Mod4+q kill + +# Another comment + +bindsym Mod4+t exec kitty +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(configFile) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds)) + } +} + +func TestSwayRealWorldConfig(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 +set $term kitty + +## Application Launchers +bindsym $mod+t exec $term +bindsym $mod+Space exec rofi + +## Window Management +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle + +## Focus Navigation +bindsym $mod+Left focus left +bindsym $mod+Right focus right + +## Workspace Navigation +bindsym $mod+1 workspace number 1 +bindsym $mod+2 workspace number 2 +bindsym $mod+Shift+1 move container to workspace number 1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(configFile) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Keybinds) < 9 { + t.Errorf("Expected at least 9 keybinds, got %d", len(section.Keybinds)) + } + + foundExec := false + foundKill := false + foundWorkspace := false + + for _, kb := range section.Keybinds { + if kb.Command == "exec kitty" { + foundExec = true + } + if kb.Command == "kill" { + foundKill = true + } + if kb.Command == "workspace number 1" { + foundWorkspace = true + } + } + + if !foundExec { + t.Error("Did not find exec kitty keybind") + } + if !foundKill { + t.Error("Did not find kill keybind") + } + if !foundWorkspace { + t.Error("Did not find workspace 1 keybind") + } +} + +func TestSwayIsMod(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"Mod4", true}, + {"Shift", true}, + {"Control", true}, + {"Alt", true}, + {"Super", true}, + {"$mod", true}, + {"Left", false}, + {"q", false}, + {"1", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := swayIsMod(tt.input) + if result != tt.expected { + t.Errorf("swayIsMod(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go new file mode 100644 index 0000000..fd476d8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go @@ -0,0 +1,293 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSwayProviderName(t *testing.T) { + provider := NewSwayProvider("") + if provider.Name() != "sway" { + t.Errorf("Name() = %q, want %q", provider.Name(), "sway") + } +} + +func TestSwayProviderDefaultPath(t *testing.T) { + provider := NewSwayProvider("") + configDir, err := os.UserConfigDir() + if err != nil { + t.Skip("UserConfigDir not available") + } + expected := filepath.Join(configDir, "sway") + if provider.configPath != expected { + t.Errorf("configPath = %q, want %q", provider.configPath, expected) + } +} + +func TestSwayProviderCustomPath(t *testing.T) { + customPath := "/custom/path" + provider := NewSwayProvider(customPath) + if provider.configPath != customPath { + t.Errorf("configPath = %q, want %q", provider.configPath, customPath) + } +} + +func TestSwayCategorizeByCommand(t *testing.T) { + tests := []struct { + command string + expected string + }{ + {"workspace number 1", "Workspace"}, + {"workspace prev", "Workspace"}, + {"workspace next", "Workspace"}, + {"move container to workspace number 1", "Workspace"}, + {"focus output left", "Monitor"}, + {"move workspace to output right", "Monitor"}, + {"kill", "Window"}, + {"fullscreen toggle", "Window"}, + {"floating toggle", "Window"}, + {"focus left", "Window"}, + {"focus right", "Window"}, + {"move left", "Window"}, + {"move right", "Window"}, + {"resize grow width 10px", "Window"}, + {"splith", "Window"}, + {"splitv", "Window"}, + {"layout tabbed", "Layout"}, + {"layout stacking", "Layout"}, + {"move scratchpad", "Scratchpad"}, + {"scratchpad show", "Scratchpad"}, + {"exec kitty", "Execute"}, + {"exec --no-startup-id firefox", "Execute"}, + {"exit", "System"}, + {"reload", "System"}, + {"unknown command", "Other"}, + } + + provider := NewSwayProvider("") + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := provider.categorizeByCommand(tt.command) + if result != tt.expected { + t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected) + } + }) + } +} + +func TestSwayFormatKey(t *testing.T) { + tests := []struct { + name string + keybind *SwayKeyBinding + expected string + }{ + { + name: "single_mod", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "q", + }, + expected: "Mod4+q", + }, + { + name: "multiple_mods", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4", "Shift"}, + Key: "e", + }, + expected: "Mod4+Shift+e", + }, + { + name: "no_mods", + keybind: &SwayKeyBinding{ + Mods: []string{}, + Key: "Print", + }, + expected: "Print", + }, + } + + provider := NewSwayProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.formatKey(tt.keybind) + if result != tt.expected { + t.Errorf("formatKey() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestSwayConvertKeybind(t *testing.T) { + tests := []struct { + name string + keybind *SwayKeyBinding + wantKey string + wantDesc string + }{ + { + name: "with_comment", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "t", + Command: "exec kitty", + Comment: "Open terminal", + }, + wantKey: "Mod4+t", + wantDesc: "Open terminal", + }, + { + name: "without_comment", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "r", + Command: "reload", + Comment: "", + }, + wantKey: "Mod4+r", + wantDesc: "reload", + }, + } + + provider := NewSwayProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.convertKeybind(tt.keybind, "") + if result.Key != tt.wantKey { + t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey) + } + if result.Description != tt.wantDesc { + t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc) + } + }) + } +} + +func TestSwayGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 +set $term kitty + +# System +bindsym $mod+Shift+c reload +bindsym $mod+Shift+e exit + +# Applications +bindsym $mod+t exec $term +bindsym $mod+Space exec rofi + +# Window Management +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle +bindsym $mod+Left focus left +bindsym $mod+Right focus right + +# Workspace +bindsym $mod+1 workspace number 1 +bindsym $mod+2 workspace number 2 +bindsym $mod+Shift+1 move container to workspace number 1 + +# Layout +bindsym $mod+s layout stacking +bindsym $mod+w layout tabbed +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewSwayProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet == nil { + t.Fatal("Expected non-nil CheatSheet") + } + + if sheet.Title != "Sway Keybinds" { + t.Errorf("Title = %q, want %q", sheet.Title, "Sway Keybinds") + } + + if sheet.Provider != "sway" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "sway") + } + + categories := []string{"System", "Execute", "Window", "Workspace", "Layout"} + for _, category := range categories { + if _, exists := sheet.Binds[category]; !exists { + t.Errorf("Expected category %q to exist", category) + } + } + + if len(sheet.Binds["System"]) < 2 { + t.Error("Expected at least 2 System keybinds") + } + if len(sheet.Binds["Execute"]) < 2 { + t.Error("Expected at least 2 Execute keybinds") + } + if len(sheet.Binds["Window"]) < 4 { + t.Error("Expected at least 4 Window keybinds") + } + if len(sheet.Binds["Workspace"]) < 3 { + t.Error("Expected at least 3 Workspace keybinds") + } +} + +func TestSwayGetCheatSheetError(t *testing.T) { + provider := NewSwayProvider("/nonexistent/path") + _, err := provider.GetCheatSheet() + if err == nil { + t.Error("Expected error for nonexistent path, got nil") + } +} + +func TestSwayIntegration(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 + +bindsym $mod+t exec kitty # Terminal +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle +bindsym $mod+1 workspace number 1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewSwayProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + totalBinds := 0 + for _, binds := range sheet.Binds { + totalBinds += len(binds) + } + + expectedBinds := 4 + if totalBinds != expectedBinds { + t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds) + } + + foundTerminal := false + for _, binds := range sheet.Binds { + for _, bind := range binds { + if bind.Description == "Terminal" && bind.Key == "Mod4+t" { + foundTerminal = true + } + } + } + + if !foundTerminal { + t.Error("Did not find terminal keybind with correct key and description") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go new file mode 100644 index 0000000..d726838 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go @@ -0,0 +1,79 @@ +package keybinds + +import ( + "fmt" + "sync" +) + +type Registry struct { + mu sync.RWMutex + providers map[string]Provider +} + +func NewRegistry() *Registry { + return &Registry{ + providers: make(map[string]Provider), + } +} + +func (r *Registry) Register(provider Provider) error { + if provider == nil { + return fmt.Errorf("cannot register nil provider") + } + + name := provider.Name() + if name == "" { + return fmt.Errorf("provider name cannot be empty") + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.providers[name]; exists { + return fmt.Errorf("provider %q already registered", name) + } + + r.providers[name] = provider + return nil +} + +func (r *Registry) Get(name string) (Provider, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + provider, exists := r.providers[name] + if !exists { + return nil, fmt.Errorf("provider %q not found", name) + } + + return provider, nil +} + +func (r *Registry) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.providers)) + for name := range r.providers { + names = append(names, name) + } + return names +} + +var defaultRegistry = NewRegistry() + +func GetDefaultRegistry() *Registry { + return defaultRegistry +} + +func Register(provider Provider) error { + return defaultRegistry.Register(provider) +} + +func Get(name string) (Provider, error) { + return defaultRegistry.Get(name) +} + +func List() []string { + return defaultRegistry.List() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go new file mode 100644 index 0000000..c3ddfee --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go @@ -0,0 +1,183 @@ +package keybinds + +import ( + "testing" +) + +type mockProvider struct { + name string + err error +} + +func (m *mockProvider) Name() string { + return m.name +} + +func (m *mockProvider) GetCheatSheet() (*CheatSheet, error) { + if m.err != nil { + return nil, m.err + } + return &CheatSheet{ + Title: "Test", + Provider: m.name, + Binds: make(map[string][]Keybind), + }, nil +} + +func TestNewRegistry(t *testing.T) { + r := NewRegistry() + if r == nil { + t.Fatal("NewRegistry returned nil") + } + + if r.providers == nil { + t.Error("providers map is nil") + } +} + +func TestRegisterProvider(t *testing.T) { + tests := []struct { + name string + provider Provider + expectError bool + errorMsg string + }{ + { + name: "valid provider", + provider: &mockProvider{name: "test"}, + expectError: false, + }, + { + name: "nil provider", + provider: nil, + expectError: true, + errorMsg: "cannot register nil provider", + }, + { + name: "empty name", + provider: &mockProvider{name: ""}, + expectError: true, + errorMsg: "provider name cannot be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRegistry() + err := r.Register(tt.provider) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestRegisterDuplicate(t *testing.T) { + r := NewRegistry() + p := &mockProvider{name: "test"} + + if err := r.Register(p); err != nil { + t.Fatalf("first registration failed: %v", err) + } + + err := r.Register(p) + if err == nil { + t.Error("expected error when registering duplicate, got nil") + } +} + +func TestGetProvider(t *testing.T) { + r := NewRegistry() + p := &mockProvider{name: "test"} + + if err := r.Register(p); err != nil { + t.Fatalf("registration failed: %v", err) + } + + retrieved, err := r.Get("test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if retrieved.Name() != "test" { + t.Errorf("Got provider name %q, want %q", retrieved.Name(), "test") + } +} + +func TestGetNonexistent(t *testing.T) { + r := NewRegistry() + + _, err := r.Get("nonexistent") + if err == nil { + t.Error("expected error for nonexistent provider, got nil") + } +} + +func TestListProviders(t *testing.T) { + r := NewRegistry() + + p1 := &mockProvider{name: "test1"} + p2 := &mockProvider{name: "test2"} + p3 := &mockProvider{name: "test3"} + + r.Register(p1) + r.Register(p2) + r.Register(p3) + + list := r.List() + + if len(list) != 3 { + t.Errorf("expected 3 providers, got %d", len(list)) + } + + found := make(map[string]bool) + for _, name := range list { + found[name] = true + } + + expected := []string{"test1", "test2", "test3"} + for _, name := range expected { + if !found[name] { + t.Errorf("expected provider %q not found in list", name) + } + } +} + +func TestDefaultRegistry(t *testing.T) { + p := &mockProvider{name: "default-test"} + + err := Register(p) + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + retrieved, err := Get("default-test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if retrieved.Name() != "default-test" { + t.Errorf("Got provider name %q, want %q", retrieved.Name(), "default-test") + } + + list := List() + found := false + for _, name := range list { + if name == "default-test" { + found = true + break + } + } + + if !found { + t.Error("provider not found in default registry list") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go new file mode 100644 index 0000000..109b53b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go @@ -0,0 +1,47 @@ +package keybinds + +type Keybind struct { + Key string `json:"key"` + Description string `json:"desc"` + Action string `json:"action,omitempty"` + Subcategory string `json:"subcat,omitempty"` + Source string `json:"source,omitempty"` + HideOnOverlay bool `json:"hideOnOverlay,omitempty"` + CooldownMs int `json:"cooldownMs,omitempty"` + Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press + AllowWhenLocked bool `json:"allowWhenLocked,omitempty"` + AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled + Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled + Conflict *Keybind `json:"conflict,omitempty"` +} + +type DMSBindsStatus struct { + Exists bool `json:"exists"` + Included bool `json:"included"` + IncludePosition int `json:"includePosition"` + TotalIncludes int `json:"totalIncludes"` + BindsAfterDMS int `json:"bindsAfterDms"` + Effective bool `json:"effective"` + OverriddenBy int `json:"overriddenBy"` + StatusMessage string `json:"statusMessage"` +} + +type CheatSheet struct { + Title string `json:"title"` + Provider string `json:"provider"` + Binds map[string][]Keybind `json:"binds"` + DMSBindsIncluded bool `json:"dmsBindsIncluded"` + DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"` +} + +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 +} |