diff --git a/core/cmd/dms/commands_keybinds.go b/core/cmd/dms/commands_keybinds.go index 17f5b3e6..6191b419 100644 --- a/core/cmd/dms/commands_keybinds.go +++ b/core/cmd/dms/commands_keybinds.go @@ -64,6 +64,11 @@ func initializeProviders() { log.Warnf("Failed to register Sway provider: %v", err) } + niriProvider := providers.NewNiriProvider("") + if err := registry.Register(niriProvider); err != nil { + log.Warnf("Failed to register Niri provider: %v", err) + } + config := keybinds.DefaultDiscoveryConfig() if err := keybinds.AutoDiscoverProviders(registry, config); err != nil { log.Warnf("Failed to auto-discover providers: %v", err) @@ -99,6 +104,8 @@ func runKeybindsShow(cmd *cobra.Command, args []string) { provider = providers.NewMangoWCProvider(customPath) case "sway": provider = providers.NewSwayProvider(customPath) + case "niri": + provider = providers.NewNiriProvider(customPath) default: log.Fatalf("Provider %s does not support custom path", providerName) } diff --git a/core/go.mod b/core/go.mod index b3f431ae..91669d75 100644 --- a/core/go.mod +++ b/core/go.mod @@ -32,6 +32,7 @@ require ( github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/stretchr/objx v0.5.3 // indirect golang.org/x/crypto v0.45.0 // indirect diff --git a/core/go.sum b/core/go.sum index 83860e3d..311eac9f 100644 --- a/core/go.sum +++ b/core/go.sum @@ -120,6 +120,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI= +github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index 287f44b5..8929ccd3 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -87,20 +87,22 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string) keybinds.Keybind { key := h.formatKey(kb) + rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) desc := kb.Comment if desc == "" { - desc = h.generateDescription(kb.Dispatcher, kb.Params) + desc = rawAction } return keybinds.Keybind{ Key: key, Description: desc, + Action: rawAction, Subcategory: subcategory, } } -func (h *HyprlandProvider) generateDescription(dispatcher, params string) string { +func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { if params != "" { return dispatcher + " " + params } diff --git a/core/internal/keybinds/providers/jsonfile.go b/core/internal/keybinds/providers/jsonfile.go index 9408035f..a4e63b5c 100644 --- a/core/internal/keybinds/providers/jsonfile.go +++ b/core/internal/keybinds/providers/jsonfile.go @@ -84,6 +84,7 @@ func (j *JSONFileProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { 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"` } @@ -100,6 +101,7 @@ func (j *JSONFileProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { kb := keybinds.Keybind{ Key: bind.Key, Description: bind.Description, + Action: bind.Action, Subcategory: bind.Subcategory, } categorizedBinds[category] = append(categorizedBinds[category], kb) diff --git a/core/internal/keybinds/providers/mangowc.go b/core/internal/keybinds/providers/mangowc.go index 8697b726..a7dce2e2 100644 --- a/core/internal/keybinds/providers/mangowc.go +++ b/core/internal/keybinds/providers/mangowc.go @@ -84,19 +84,21 @@ func (m *MangoWCProvider) categorizeByCommand(command string) string { func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind { key := m.formatKey(kb) + rawAction := m.formatRawAction(kb.Command, kb.Params) desc := kb.Comment if desc == "" { - desc = m.generateDescription(kb.Command, kb.Params) + desc = rawAction } return keybinds.Keybind{ Key: key, Description: desc, + Action: rawAction, } } -func (m *MangoWCProvider) generateDescription(command, params string) string { +func (m *MangoWCProvider) formatRawAction(command, params string) string { if params != "" { return command + " " + params } diff --git a/core/internal/keybinds/providers/niri.go b/core/internal/keybinds/providers/niri.go new file mode 100644 index 00000000..18dd8d7b --- /dev/null +++ b/core/internal/keybinds/providers/niri.go @@ -0,0 +1,137 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" +) + +type NiriProvider struct { + configDir string +} + +func NewNiriProvider(configDir string) *NiriProvider { + if configDir == "" { + configDir = defaultNiriConfigDir() + } + return &NiriProvider{ + configDir: configDir, + } +} + +func defaultNiriConfigDir() string { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome != "" { + return filepath.Join(configHome, "niri") + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "niri") +} + +func (n *NiriProvider) Name() string { + return "niri" +} + +func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + section, err := ParseNiriKeys(n.configDir) + if err != nil { + return nil, fmt.Errorf("failed to parse niri config: %w", err) + } + + categorizedBinds := make(map[string][]keybinds.Keybind) + n.convertSection(section, "", categorizedBinds) + + return &keybinds.CheatSheet{ + Title: "Niri Keybinds", + Provider: n.Name(), + Binds: categorizedBinds, + }, nil +} + +func (n *NiriProvider) convertSection(section *NiriSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) { + currentSubcat := subcategory + if section.Name != "" { + currentSubcat = section.Name + } + + for _, kb := range section.Keybinds { + category := n.categorizeByAction(kb.Action) + bind := n.convertKeybind(&kb, currentSubcat) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + for _, child := range section.Children { + n.convertSection(&child, currentSubcat, categorizedBinds) + } +} + +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 == "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) keybinds.Keybind { + key := n.formatKey(kb) + desc := kb.Description + rawAction := n.formatRawAction(kb.Action, kb.Args) + + if desc == "" { + desc = rawAction + } + + return keybinds.Keybind{ + Key: key, + Description: desc, + Action: rawAction, + Subcategory: subcategory, + } +} + +func (n *NiriProvider) formatRawAction(action string, args []string) string { + if len(args) == 0 { + return action + } + return action + " " + strings.Join(args, " ") +} + +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, "+") +} diff --git a/core/internal/keybinds/providers/niri_parser.go b/core/internal/keybinds/providers/niri_parser.go new file mode 100644 index 00000000..47dff6c1 --- /dev/null +++ b/core/internal/keybinds/providers/niri_parser.go @@ -0,0 +1,229 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "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 +} + +type NiriSection struct { + Name string + Keybinds []NiriKeyBinding + Children []NiriSection +} + +type NiriParser struct { + configDir string + processedFiles map[string]bool + bindMap map[string]*NiriKeyBinding + bindOrder []string +} + +func NewNiriParser(configDir string) *NiriParser { + return &NiriParser{ + configDir: configDir, + processedFiles: make(map[string]bool), + bindMap: make(map[string]*NiriKeyBinding), + bindOrder: []string{}, + } +} + +func (p *NiriParser) Parse() (*NiriSection, error) { + configPath := filepath.Join(p.configDir, "config.kdl") + section, err := p.parseFile(configPath, "") + if err != nil { + return nil, err + } + + section.Keybinds = p.finalizeBinds() + return section, nil +} + +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) + 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, + } + + baseDir := filepath.Dir(absPath) + p.processNodes(doc.Nodes, section, baseDir) + + 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 := node.Arguments[0].String() + includePath = strings.Trim(includePath, "\"") + + var fullPath string + if filepath.IsAbs(includePath) { + fullPath = includePath + } else { + fullPath = filepath.Join(baseDir, includePath) + } + + includedSection, err := p.parseFile(fullPath, "") + if err != nil { + return + } + + section.Children = append(section.Children, includedSection.Children...) +} + +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, subcategory 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, strings.Trim(arg.String(), "\"")) + } + } + + description := "" + if node.Properties != nil { + if val, ok := node.Properties.Get("hotkey-overlay-title"); ok { + description = strings.Trim(val.String(), "\"") + } + } + + return &NiriKeyBinding{ + Mods: mods, + Key: key, + Action: action, + Args: args, + Description: description, + } +} + +func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) { + parts := strings.Split(combo, "+") + if len(parts) == 0 { + return nil, combo + } + + if len(parts) == 1 { + return nil, parts[0] + } + + mods := parts[:len(parts)-1] + key := parts[len(parts)-1] + + return mods, key +} + +func ParseNiriKeys(configDir string) (*NiriSection, error) { + parser := NewNiriParser(configDir) + return parser.Parse() +} diff --git a/core/internal/keybinds/providers/niri_parser_test.go b/core/internal/keybinds/providers/niri_parser_test.go new file mode 100644 index 00000000..7cc8014e --- /dev/null +++ b/core/internal/keybinds/providers/niri_parser_test.go @@ -0,0 +1,498 @@ +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), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds)) + } + + foundClose := false + foundFullscreen := false + foundTerminal := false + + for _, kb := range 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), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds from recent-windows, got %d", len(section.Keybinds)) + } + + foundNext := false + foundPrev := false + + for _, kb := range 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, 0755); 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), 0644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (1 main + 1 include), got %d", len(section.Keybinds)) + } +} + +func TestNiriParseIncludeOverride(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(subDir, 0755); 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), 0644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 1 { + t.Errorf("Expected 1 keybind (later overrides earlier), got %d", len(section.Keybinds)) + } + + if len(section.Keybinds) > 0 { + kb := 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), 0644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(otherConfig, []byte(otherContent), 0644); err != nil { + t.Fatalf("Failed to write other config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed (should handle circular includes): %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (circular include handled), got %d", len(section.Keybinds)) + } +} + +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), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed (should skip missing include): %v", err) + } + + if len(section.Keybinds) != 1 { + t.Errorf("Expected 1 keybind (missing include skipped), got %d", len(section.Keybinds)) + } +} + +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), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 0 { + t.Errorf("Expected 0 keybinds, got %d", len(section.Keybinds)) + } +} + +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), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 3 { + t.Fatalf("Expected 3 unique keybinds, got %d", len(section.Keybinds)) + } + + var modT *NiriKeyBinding + for i := range section.Keybinds { + kb := §ion.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, 0755); 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), 0644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 4 { + t.Errorf("Expected 4 unique keybinds, got %d", len(section.Keybinds)) + } + + bindMap := make(map[string]*NiriKeyBinding) + for i := range section.Keybinds { + kb := §ion.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), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(section.Keybinds) != 1 { + t.Fatalf("Expected 1 keybind, got %d", len(section.Keybinds)) + } + + kb := 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) + } + } +} diff --git a/core/internal/keybinds/providers/niri_test.go b/core/internal/keybinds/providers/niri_test.go new file mode 100644 index 00000000..4ece0dfd --- /dev/null +++ b/core/internal/keybinds/providers/niri_test.go @@ -0,0 +1,261 @@ +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), 0644); 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"}, + {"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 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), 0644); 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"])) + } +} diff --git a/core/internal/keybinds/providers/sway.go b/core/internal/keybinds/providers/sway.go index f7e39eb2..811dff9c 100644 --- a/core/internal/keybinds/providers/sway.go +++ b/core/internal/keybinds/providers/sway.go @@ -99,6 +99,7 @@ func (s *SwayProvider) convertKeybind(kb *SwayKeyBinding, subcategory string) ke return keybinds.Keybind{ Key: key, Description: desc, + Action: kb.Command, Subcategory: subcategory, } } diff --git a/core/internal/keybinds/types.go b/core/internal/keybinds/types.go index d89111fc..1c163ed6 100644 --- a/core/internal/keybinds/types.go +++ b/core/internal/keybinds/types.go @@ -3,6 +3,7 @@ package keybinds type Keybind struct { Key string `json:"key"` Description string `json:"desc"` + Action string `json:"action,omitempty"` Subcategory string `json:"subcat,omitempty"` } diff --git a/quickshell/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml index 2b1467c0..da6b4a05 100644 --- a/quickshell/Modals/Common/DankModal.qml +++ b/quickshell/Modals/Common/DankModal.qml @@ -44,6 +44,7 @@ Item { property bool keepContentLoaded: false property bool keepPopoutsOpen: false property var customKeyboardFocus: null + property bool useOverlayLayer: false readonly property alias contentWindow: contentWindow readonly property alias backgroundWindow: backgroundWindow @@ -148,6 +149,7 @@ Item { id: backgroundWindow visible: false color: "transparent" + screen: root.effectiveScreen WlrLayershell.namespace: root.layerNamespace + ":background" WlrLayershell.layer: WlrLayershell.Top @@ -207,9 +209,12 @@ Item { id: contentWindow visible: false color: "transparent" + screen: root.effectiveScreen WlrLayershell.namespace: root.layerNamespace WlrLayershell.layer: { + if (root.useOverlayLayer) + return WlrLayershell.Overlay; switch (Quickshell.env("DMS_MODAL_LAYER")) { case "bottom": console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer."); diff --git a/quickshell/Modals/KeybindsModal.qml b/quickshell/Modals/KeybindsModal.qml index 722ab406..9605f5e0 100644 --- a/quickshell/Modals/KeybindsModal.qml +++ b/quickshell/Modals/KeybindsModal.qml @@ -1,6 +1,5 @@ import QtQuick -import QtQuick.Controls -import Quickshell +import Quickshell.Hyprland import qs.Common import qs.Modals.Common import qs.Services @@ -10,33 +9,52 @@ DankModal { id: root layerNamespace: "dms:keybinds" + useOverlayLayer: true property real scrollStep: 60 property var activeFlickable: null property real _maxW: Math.min(Screen.width * 0.92, 1200) property real _maxH: Math.min(Screen.height * 0.92, 900) - width: _maxW - height: _maxH + modalWidth: _maxW + modalHeight: _maxH onBackgroundClicked: close() + onOpened: () => Qt.callLater(() => modalFocusScope.forceActiveFocus()) + + HyprlandFocusGrab { + windows: [root.contentWindow] + active: CompositorService.isHyprland && root.shouldHaveFocus + } function scrollDown() { - if (!root.activeFlickable) return - let newY = root.activeFlickable.contentY + scrollStep - newY = Math.min(newY, root.activeFlickable.contentHeight - root.activeFlickable.height) - root.activeFlickable.contentY = newY + if (!root.activeFlickable) + return; + let newY = root.activeFlickable.contentY + scrollStep; + newY = Math.min(newY, root.activeFlickable.contentHeight - root.activeFlickable.height); + root.activeFlickable.contentY = newY; } function scrollUp() { - if (!root.activeFlickable) return - let newY = root.activeFlickable.contentY - root.scrollStep - newY = Math.max(0, newY) - root.activeFlickable.contentY = newY + if (!root.activeFlickable) + return; + let newY = root.activeFlickable.contentY - root.scrollStep; + newY = Math.max(0, newY); + root.activeFlickable.contentY = newY; } - Shortcut { sequence: "Ctrl+j"; onActivated: root.scrollDown() } - Shortcut { sequence: "Down"; onActivated: root.scrollDown() } - Shortcut { sequence: "Ctrl+k"; onActivated: root.scrollUp() } - Shortcut { sequence: "Up"; onActivated: root.scrollUp() } - Shortcut { sequence: "Esc"; onActivated: root.close() } + modalFocusScope.Keys.onPressed: event => { + if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) { + scrollDown(); + event.accepted = true; + } else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) { + scrollUp(); + event.accepted = true; + } else if (event.key === Qt.Key_Down) { + scrollDown(); + event.accepted = true; + } else if (event.key === Qt.Key_Up) { + scrollUp(); + event.accepted = true; + } + } content: Component { Item { @@ -66,25 +84,25 @@ DankModal { property var rawBinds: KeybindsService.keybinds.binds || {} property var categories: { - const processed = {} + const processed = {}; for (const cat in rawBinds) { - const binds = rawBinds[cat] - const subcats = {} - let hasSubcats = false + const binds = rawBinds[cat]; + const subcats = {}; + let hasSubcats = false; for (let i = 0; i < binds.length; i++) { - const bind = binds[i] + const bind = binds[i]; if (bind.subcat) { - hasSubcats = true + hasSubcats = true; if (!subcats[bind.subcat]) { - subcats[bind.subcat] = [] + subcats[bind.subcat] = []; } - subcats[bind.subcat].push(bind) + subcats[bind.subcat].push(bind); } else { if (!subcats["_root"]) { - subcats["_root"] = [] + subcats["_root"] = []; } - subcats["_root"].push(bind) + subcats["_root"].push(bind); } } @@ -92,21 +110,21 @@ DankModal { hasSubcats: hasSubcats, subcats: subcats, subcatKeys: Object.keys(subcats) - } + }; } - return processed + return processed; } property var categoryKeys: Object.keys(categories) function distributeCategories(cols) { - const columns = [] + const columns = []; for (let i = 0; i < cols; i++) { - columns.push([]) + columns.push([]); } for (let i = 0; i < categoryKeys.length; i++) { - columns[i % cols].push(categoryKeys[i]) + columns[i % cols].push(categoryKeys[i]); } - return columns + return columns; } Row { @@ -136,92 +154,95 @@ DankModal { property string catName: modelData property var catData: mainFlickable.categories[catName] - StyledText { - text: categoryColumn.catName - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Bold - color: Theme.primary - } + StyledText { + text: categoryColumn.catName + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Bold + color: Theme.primary + } - Rectangle { - width: parent.width - height: 1 - color: Theme.primary - opacity: 0.3 - } + Rectangle { + width: parent.width + height: 1 + color: Theme.primary + opacity: 0.3 + } - Item { width: 1; height: Theme.spacingXS } - - Column { - width: parent.width - spacing: Theme.spacingM - - Repeater { - model: categoryColumn.catData?.subcatKeys || [] + Item { + width: 1 + height: Theme.spacingXS + } Column { width: parent.width - spacing: Theme.spacingXS + spacing: Theme.spacingM - property string subcatName: modelData - property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || [] + Repeater { + model: categoryColumn.catData?.subcatKeys || [] - StyledText { - visible: parent.subcatName !== "_root" - text: parent.subcatName - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.DemiBold - color: Theme.primary - opacity: 0.7 - } + Column { + width: parent.width + spacing: Theme.spacingXS - Column { - width: parent.width - spacing: Theme.spacingXS + property string subcatName: modelData + property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || [] - Repeater { - model: parent.parent.subcatBinds + StyledText { + visible: parent.subcatName !== "_root" + text: parent.subcatName + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + color: Theme.primary + opacity: 0.7 + } - Row { + Column { width: parent.width - spacing: Theme.spacingS + spacing: Theme.spacingXS - StyledRect { - width: Math.min(140, parent.width * 0.42) - height: 22 - radius: 4 - opacity: 0.9 + Repeater { + model: parent.parent.subcatBinds - StyledText { - anchors.centerIn: parent - anchors.margins: 2 - width: parent.width - 4 - color: Theme.secondary - text: modelData.key || "" - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - isMonospace: true - elide: Text.ElideRight - horizontalAlignment: Text.AlignHCenter + Row { + width: parent.width + spacing: Theme.spacingS + + StyledRect { + width: Math.min(140, parent.width * 0.42) + height: 22 + radius: 4 + opacity: 0.9 + + StyledText { + anchors.centerIn: parent + anchors.margins: 2 + width: parent.width - 4 + color: Theme.secondary + text: modelData.key || "" + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + isMonospace: true + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + } + + StyledText { + width: parent.width - 150 + text: modelData.desc || "" + font.pixelSize: Theme.fontSizeSmall + opacity: 0.9 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } } } - - StyledText { - width: parent.width - 150 - text: modelData.desc || "" - font.pixelSize: Theme.fontSizeSmall - opacity: 0.9 - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } } } } } } } - } - } } } }