summaryrefslogtreecommitdiff
path: root/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds
diff options
context:
space:
mode:
authornippy <you@example.com>2026-04-18 13:49:56 +0200
committernippy <you@example.com>2026-04-18 13:49:56 +0200
commit5d94c0a7d44a2255b81815a52a7056a94a39842d (patch)
tree759bdea9645c6a62f9e1e4c001f7d81cccd120d2 /raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds
parente79cdf210b267f21a186255ce1a4d50029439d54 (diff)
downloadRaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.tar.gz
RaveOS-PKGBUILD-5d94c0a7d44a2255b81815a52a7056a94a39842d.zip
update Raveos themes
Diffstat (limited to 'raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds')
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go107
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go287
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go497
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go627
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go519
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go220
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go119
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go281
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go442
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go613
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go499
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go320
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go95
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go320
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go650
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go400
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go630
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go661
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go150
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go364
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go471
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go293
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go79
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go183
-rw-r--r--raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go47
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
+}