1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-28 15:32:50 -05:00

keyboard shortcuts: comprehensive keyboard shortcut management interface

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -186,6 +186,144 @@ func TestNiriDefaultConfigDir(t *testing.T) {
} }
} }
func TestNiriGenerateBindsContent(t *testing.T) {
provider := NewNiriProvider("")
tests := []struct {
name string
binds map[string]*overrideBind
expected string
}{
{
name: "empty binds",
binds: map[string]*overrideBind{},
expected: "binds {}\n",
},
{
name: "simple spawn bind",
binds: map[string]*overrideBind{
"Mod+T": {
Key: "Mod+T",
Action: "spawn kitty",
Description: "Open Terminal",
},
},
expected: `binds {
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
}
`,
},
{
name: "spawn with multiple args",
binds: map[string]*overrideBind{
"Mod+Space": {
Key: "Mod+Space",
Action: `spawn "dms" "ipc" "call" "spotlight" "toggle"`,
Description: "Application Launcher",
},
},
expected: `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
}
`,
},
{
name: "bind with allow-when-locked",
binds: map[string]*overrideBind{
"XF86AudioMute": {
Key: "XF86AudioMute",
Action: `spawn "dms" "ipc" "call" "audio" "mute"`,
Options: map[string]any{"allow-when-locked": true},
},
},
expected: `binds {
XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; }
}
`,
},
{
name: "simple action without args",
binds: map[string]*overrideBind{
"Mod+Q": {
Key: "Mod+Q",
Action: "close-window",
Description: "Close Window",
},
},
expected: `binds {
Mod+Q hotkey-overlay-title="Close Window" { close-window; }
}
`,
},
{
name: "recent-windows action",
binds: map[string]*overrideBind{
"Alt+Tab": {
Key: "Alt+Tab",
Action: "next-window",
},
},
expected: `binds {
}
recent-windows {
binds {
Alt+Tab { next-window; }
}
}
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.generateBindsContent(tt.binds)
if result != tt.expected {
t.Errorf("generateBindsContent() =\n%q\nwant:\n%q", result, tt.expected)
}
})
}
}
func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
provider := NewNiriProvider("")
binds := map[string]*overrideBind{
"Mod+Space": {
Key: "Mod+Space",
Action: `spawn "dms" "ipc" "call" "spotlight" "toggle"`,
Description: "Application Launcher",
},
"XF86AudioMute": {
Key: "XF86AudioMute",
Action: `spawn "dms" "ipc" "call" "audio" "mute"`,
Options: map[string]any{"allow-when-locked": true},
},
"Mod+Q": {
Key: "Mod+Q",
Action: "close-window",
Description: "Close Window",
},
}
content := provider.generateBindsContent(binds)
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write temp file: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("Failed to parse generated content: %v\nContent was:\n%s", err, content)
}
if len(result.Section.Keybinds) != 3 {
t.Errorf("Expected 3 keybinds after round-trip, got %d", len(result.Section.Keybinds))
}
}
func TestNiriProviderWithRealWorldConfig(t *testing.T) { func TestNiriProviderWithRealWorldConfig(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")

View File

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

View File

@@ -329,79 +329,79 @@ Item {
IpcHandler { IpcHandler {
function toggle(provider: string): string { function toggle(provider: string): string {
if (!provider) { if (!provider)
return "ERROR: No provider specified"; return "ERROR: No provider specified";
}
KeybindsService.loadProvider(provider); KeybindsService.currentProvider = provider;
KeybindsService.loadBinds();
root.hyprKeybindsModalLoader.active = true; root.hyprKeybindsModalLoader.active = true;
if (root.hyprKeybindsModalLoader.item) { if (!root.hyprKeybindsModalLoader.item)
if (root.hyprKeybindsModalLoader.item.shouldBeVisible) { return `KEYBINDS_TOGGLE_FAILED: ${provider}`;
root.hyprKeybindsModalLoader.item.close();
} else { if (root.hyprKeybindsModalLoader.item.shouldBeVisible) {
root.hyprKeybindsModalLoader.item.open(); root.hyprKeybindsModalLoader.item.close();
} } else {
return `KEYBINDS_TOGGLE_SUCCESS: ${provider}`; root.hyprKeybindsModalLoader.item.open();
} }
return `KEYBINDS_TOGGLE_FAILED: ${provider}`; return `KEYBINDS_TOGGLE_SUCCESS: ${provider}`;
} }
function toggleWithPath(provider: string, path: string): string { function toggleWithPath(provider: string, path: string): string {
if (!provider) { if (!provider)
return "ERROR: No provider specified"; return "ERROR: No provider specified";
}
KeybindsService.loadProviderWithPath(provider, path); KeybindsService.currentProvider = provider;
KeybindsService.loadBinds();
root.hyprKeybindsModalLoader.active = true; root.hyprKeybindsModalLoader.active = true;
if (root.hyprKeybindsModalLoader.item) { if (!root.hyprKeybindsModalLoader.item)
if (root.hyprKeybindsModalLoader.item.shouldBeVisible) { return `KEYBINDS_TOGGLE_FAILED: ${provider}`;
root.hyprKeybindsModalLoader.item.close();
} else { if (root.hyprKeybindsModalLoader.item.shouldBeVisible) {
root.hyprKeybindsModalLoader.item.open(); root.hyprKeybindsModalLoader.item.close();
} } else {
return `KEYBINDS_TOGGLE_SUCCESS: ${provider} (${path})`; root.hyprKeybindsModalLoader.item.open();
} }
return `KEYBINDS_TOGGLE_FAILED: ${provider}`; return `KEYBINDS_TOGGLE_SUCCESS: ${provider} (${path})`;
} }
function open(provider: string): string { function open(provider: string): string {
if (!provider) { if (!provider)
return "ERROR: No provider specified"; return "ERROR: No provider specified";
}
KeybindsService.loadProvider(provider); KeybindsService.currentProvider = provider;
KeybindsService.loadBinds();
root.hyprKeybindsModalLoader.active = true; root.hyprKeybindsModalLoader.active = true;
if (root.hyprKeybindsModalLoader.item) { if (!root.hyprKeybindsModalLoader.item)
root.hyprKeybindsModalLoader.item.open(); return `KEYBINDS_OPEN_FAILED: ${provider}`;
return `KEYBINDS_OPEN_SUCCESS: ${provider}`;
} root.hyprKeybindsModalLoader.item.open();
return `KEYBINDS_OPEN_FAILED: ${provider}`; return `KEYBINDS_OPEN_SUCCESS: ${provider}`;
} }
function openWithPath(provider: string, path: string): string { function openWithPath(provider: string, path: string): string {
if (!provider) { if (!provider)
return "ERROR: No provider specified"; return "ERROR: No provider specified";
}
KeybindsService.loadProviderWithPath(provider, path); KeybindsService.currentProvider = provider;
KeybindsService.loadBinds();
root.hyprKeybindsModalLoader.active = true; root.hyprKeybindsModalLoader.active = true;
if (root.hyprKeybindsModalLoader.item) { if (!root.hyprKeybindsModalLoader.item)
root.hyprKeybindsModalLoader.item.open(); return `KEYBINDS_OPEN_FAILED: ${provider}`;
return `KEYBINDS_OPEN_SUCCESS: ${provider} (${path})`;
} root.hyprKeybindsModalLoader.item.open();
return `KEYBINDS_OPEN_FAILED: ${provider}`; return `KEYBINDS_OPEN_SUCCESS: ${provider} (${path})`;
} }
function close(): string { function close(): string {
if (root.hyprKeybindsModalLoader.item) { if (!root.hyprKeybindsModalLoader.item)
root.hyprKeybindsModalLoader.item.close(); return "KEYBINDS_CLOSE_FAILED";
return "KEYBINDS_CLOSE_SUCCESS";
} root.hyprKeybindsModalLoader.item.close();
return "KEYBINDS_CLOSE_FAILED"; return "KEYBINDS_CLOSE_SUCCESS";
} }
target: "keybinds" target: "keybinds"
@@ -409,44 +409,48 @@ Item {
IpcHandler { IpcHandler {
function openBinds(): string { function openBinds(): string {
if (!CompositorService.isHyprland) { if (!CompositorService.isHyprland)
return "HYPR_NOT_AVAILABLE"; return "HYPR_NOT_AVAILABLE";
}
KeybindsService.loadProvider("hyprland"); KeybindsService.currentProvider = "hyprland";
KeybindsService.loadBinds();
root.hyprKeybindsModalLoader.active = true; root.hyprKeybindsModalLoader.active = true;
if (root.hyprKeybindsModalLoader.item) {
root.hyprKeybindsModalLoader.item.open(); if (!root.hyprKeybindsModalLoader.item)
return "HYPR_KEYBINDS_OPEN_SUCCESS"; return "HYPR_KEYBINDS_OPEN_FAILED";
}
return "HYPR_KEYBINDS_OPEN_FAILED"; root.hyprKeybindsModalLoader.item.open();
return "HYPR_KEYBINDS_OPEN_SUCCESS";
} }
function closeBinds(): string { function closeBinds(): string {
if (!CompositorService.isHyprland) { if (!CompositorService.isHyprland)
return "HYPR_NOT_AVAILABLE"; return "HYPR_NOT_AVAILABLE";
}
if (root.hyprKeybindsModalLoader.item) { if (!root.hyprKeybindsModalLoader.item)
root.hyprKeybindsModalLoader.item.close(); return "HYPR_KEYBINDS_CLOSE_FAILED";
return "HYPR_KEYBINDS_CLOSE_SUCCESS";
} root.hyprKeybindsModalLoader.item.close();
return "HYPR_KEYBINDS_CLOSE_FAILED"; return "HYPR_KEYBINDS_CLOSE_SUCCESS";
} }
function toggleBinds(): string { function toggleBinds(): string {
if (!CompositorService.isHyprland) { if (!CompositorService.isHyprland)
return "HYPR_NOT_AVAILABLE"; return "HYPR_NOT_AVAILABLE";
}
KeybindsService.loadProvider("hyprland"); KeybindsService.currentProvider = "hyprland";
KeybindsService.loadBinds();
root.hyprKeybindsModalLoader.active = true; root.hyprKeybindsModalLoader.active = true;
if (root.hyprKeybindsModalLoader.item) {
if (root.hyprKeybindsModalLoader.item.shouldBeVisible) { if (!root.hyprKeybindsModalLoader.item)
root.hyprKeybindsModalLoader.item.close(); return "HYPR_KEYBINDS_TOGGLE_FAILED";
} else {
root.hyprKeybindsModalLoader.item.open(); if (root.hyprKeybindsModalLoader.item.shouldBeVisible) {
} root.hyprKeybindsModalLoader.item.close();
return "HYPR_KEYBINDS_TOGGLE_SUCCESS"; } else {
root.hyprKeybindsModalLoader.item.open();
} }
return "HYPR_KEYBINDS_TOGGLE_FAILED"; return "HYPR_KEYBINDS_TOGGLE_SUCCESS";
} }
function toggleOverview(): string { function toggleOverview(): string {
@@ -490,60 +494,108 @@ Item {
function getBarConfig(selector: string, value: string): var { function getBarConfig(selector: string, value: string): var {
const barSelectors = ["id", "name", "index"]; const barSelectors = ["id", "name", "index"];
if (!barSelectors.includes(selector)) return { error: "BAR_INVALID_SELECTOR" }; if (!barSelectors.includes(selector))
return {
error: "BAR_INVALID_SELECTOR"
};
const index = selector === "index" ? Number(value) : SettingsData.barConfigs.findIndex(bar => bar[selector] == value); const index = selector === "index" ? Number(value) : SettingsData.barConfigs.findIndex(bar => bar[selector] == value);
const barConfig = SettingsData.barConfigs?.[index]; const barConfig = SettingsData.barConfigs?.[index];
if (!barConfig) return { error: "BAR_NOT_FOUND" }; if (!barConfig)
return { barConfig }; return {
error: "BAR_NOT_FOUND"
};
return {
barConfig
};
} }
IpcHandler { IpcHandler {
function reveal(selector: string, value: string): string { function reveal(selector: string, value: string): string {
const { barConfig, error } = getBarConfig(selector, value); const {
if (error) return error; barConfig,
SettingsData.updateBarConfig(barConfig.id, {visible: true}); error
} = getBarConfig(selector, value);
if (error)
return error;
SettingsData.updateBarConfig(barConfig.id, {
visible: true
});
return "BAR_SHOW_SUCCESS"; return "BAR_SHOW_SUCCESS";
} }
function hide(selector: string, value: string): string { function hide(selector: string, value: string): string {
const { barConfig, error } = getBarConfig(selector, value); const {
if (error) return error; barConfig,
SettingsData.updateBarConfig(barConfig.id, {visible: false}); error
} = getBarConfig(selector, value);
if (error)
return error;
SettingsData.updateBarConfig(barConfig.id, {
visible: false
});
return "BAR_HIDE_SUCCESS"; return "BAR_HIDE_SUCCESS";
} }
function toggle(selector: string, value: string): string { function toggle(selector: string, value: string): string {
const { barConfig, error } = getBarConfig(selector, value); const {
if (error) return error; barConfig,
SettingsData.updateBarConfig(barConfig.id, {visible: !barConfig.visible}); error
} = getBarConfig(selector, value);
if (error)
return error;
SettingsData.updateBarConfig(barConfig.id, {
visible: !barConfig.visible
});
return !barConfig.visible ? "BAR_SHOW_SUCCESS" : "BAR_HIDE_SUCCESS"; return !barConfig.visible ? "BAR_SHOW_SUCCESS" : "BAR_HIDE_SUCCESS";
} }
function status(selector: string, value: string): string { function status(selector: string, value: string): string {
const { barConfig, error } = getBarConfig(selector, value); const {
if (error) return error; barConfig,
error
} = getBarConfig(selector, value);
if (error)
return error;
return barConfig.visible ? "visible" : "hidden"; return barConfig.visible ? "visible" : "hidden";
} }
function autoHide(selector: string, value: string): string { function autoHide(selector: string, value: string): string {
const { barConfig, error } = getBarConfig(selector, value); const {
if (error) return error; barConfig,
SettingsData.updateBarConfig(barConfig.id, {autoHide: true}); error
} = getBarConfig(selector, value);
if (error)
return error;
SettingsData.updateBarConfig(barConfig.id, {
autoHide: true
});
return "BAR_AUTO_HIDE_SUCCESS"; return "BAR_AUTO_HIDE_SUCCESS";
} }
function manualHide(selector: string, value: string): string { function manualHide(selector: string, value: string): string {
const { barConfig, error } = getBarConfig(selector, value); const {
if (error) return error; barConfig,
SettingsData.updateBarConfig(barConfig.id, {autoHide: false}); error
} = getBarConfig(selector, value);
if (error)
return error;
SettingsData.updateBarConfig(barConfig.id, {
autoHide: false
});
return "BAR_MANUAL_HIDE_SUCCESS"; return "BAR_MANUAL_HIDE_SUCCESS";
} }
function toggleAutoHide(selector: string, value: string): string { function toggleAutoHide(selector: string, value: string): string {
const { barConfig, error } = getBarConfig(selector, value); const {
if (error) return error; barConfig,
SettingsData.updateBarConfig(barConfig.id, {autoHide: !barConfig.autoHide}); error
return barConfig.autoHide ? "BAR_MANUAL_HIDE_SUCCESS": "BAR_AUTO_HIDE_SUCCESS"; } = getBarConfig(selector, value);
if (error)
return error;
SettingsData.updateBarConfig(barConfig.id, {
autoHide: !barConfig.autoHide
});
return barConfig.autoHide ? "BAR_MANUAL_HIDE_SUCCESS" : "BAR_AUTO_HIDE_SUCCESS";
} }
target: "bar" target: "bar"
@@ -570,20 +622,20 @@ Item {
} }
function autoHide(): string { function autoHide(): string {
SettingsData.dockAutoHide = true SettingsData.dockAutoHide = true;
SettingsData.saveSettings() SettingsData.saveSettings();
return "BAR_AUTO_HIDE_SUCCESS"; return "BAR_AUTO_HIDE_SUCCESS";
} }
function manualHide(): string { function manualHide(): string {
SettingsData.dockAutoHide = false SettingsData.dockAutoHide = false;
SettingsData.saveSettings() SettingsData.saveSettings();
return "BAR_MANUAL_HIDE_SUCCESS"; return "BAR_MANUAL_HIDE_SUCCESS";
} }
function toggleAutoHide(): string { function toggleAutoHide(): string {
SettingsData.dockAutoHide = !SettingsData.dockAutoHide SettingsData.dockAutoHide = !SettingsData.dockAutoHide;
SettingsData.saveSettings() SettingsData.saveSettings();
return SettingsData.dockAutoHide ? "BAR_AUTO_HIDE_SUCCESS" : "BAR_MANUAL_HIDE_SUCCESS"; return SettingsData.dockAutoHide ? "BAR_AUTO_HIDE_SUCCESS" : "BAR_MANUAL_HIDE_SUCCESS";
} }
@@ -612,49 +664,51 @@ Item {
} }
function get(key: string): string { function get(key: string): string {
return JSON.stringify(SettingsData?.[key]) return JSON.stringify(SettingsData?.[key]);
} }
function set(key: string, value: string): string { function set(key: string, value: string): string {
if (!(key in SettingsData)) { if (!(key in SettingsData)) {
console.warn("Cannot set property, not found:", key) console.warn("Cannot set property, not found:", key);
return "SETTINGS_INVALID_KEY" return "SETTINGS_INVALID_KEY";
} }
const typeName = typeof SettingsData?.[key] const typeName = typeof SettingsData?.[key];
try { try {
switch (typeName) { switch (typeName) {
case "boolean": case "boolean":
if (value === "true" || value === "false") value = (value === "true") if (value === "true" || value === "false")
else throw `${value} is not a Boolean` value = (value === "true");
break else
throw `${value} is not a Boolean`;
break;
case "number": case "number":
value = Number(value) value = Number(value);
if (isNaN(value)) throw `${value} is not a Number` if (isNaN(value))
break throw `${value} is not a Number`;
break;
case "string": case "string":
value = String(value) value = String(value);
break break;
case "object": case "object":
// NOTE: Parsing lists is messed up upstream and not sure if we want // NOTE: Parsing lists is messed up upstream and not sure if we want
// to make sure objects are well structured or just let people set // to make sure objects are well structured or just let people set
// whatever they want but risking messed up settings. // whatever they want but risking messed up settings.
// Objects & Arrays are disabled for now // Objects & Arrays are disabled for now
// https://github.com/quickshell-mirror/quickshell/pull/22 // https://github.com/quickshell-mirror/quickshell/pull/22
throw "Setting Objects and Arrays not supported" throw "Setting Objects and Arrays not supported";
default: default:
throw "Unsupported type" throw "Unsupported type";
} }
console.warn("Setting:", key, value) console.warn("Setting:", key, value);
SettingsData[key] = value SettingsData[key] = value;
SettingsData.saveSettings() SettingsData.saveSettings();
return "SETTINGS_SET_SUCCESS" return "SETTINGS_SET_SUCCESS";
} catch (e) { } catch (e) {
console.warn("Failed to set property:", key, "error:", e) console.warn("Failed to set property:", key, "error:", e);
return "SETTINGS_SET_FAILURE" return "SETTINGS_SET_FAILURE";
} }
} }

View File

@@ -97,14 +97,12 @@ DankModal {
const bind = binds[i]; const bind = binds[i];
if (bind.subcat) { if (bind.subcat) {
hasSubcats = true; hasSubcats = true;
if (!subcats[bind.subcat]) { if (!subcats[bind.subcat])
subcats[bind.subcat] = []; subcats[bind.subcat] = [];
}
subcats[bind.subcat].push(bind); subcats[bind.subcat].push(bind);
} else { } else {
if (!subcats["_root"]) { if (!subcats["_root"])
subcats["_root"] = []; subcats["_root"] = [];
}
subcats["_root"].push(bind); subcats["_root"].push(bind);
} }
} }
@@ -121,12 +119,10 @@ DankModal {
function distributeCategories(cols) { function distributeCategories(cols) {
const columns = []; const columns = [];
for (let i = 0; i < cols; i++) { for (let i = 0; i < cols; i++)
columns.push([]); columns.push([]);
} for (let i = 0; i < categoryKeys.length; i++)
for (let i = 0; i < categoryKeys.length; i++) {
columns[i % cols].push(categoryKeys[i]); columns[i % cols].push(categoryKeys[i]);
}
return columns; return columns;
} }

View File

@@ -57,13 +57,32 @@ FocusScope {
} }
Loader { Loader {
id: topBarLoader id: keybindsLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 2 active: root.currentIndex === 2
visible: active visible: active
focus: active focus: active
sourceComponent: KeybindsTab {
parentModal: root.parentModal
}
onActiveChanged: {
if (active && item) {
Qt.callLater(() => item.forceActiveFocus());
}
}
}
Loader {
id: topBarLoader
anchors.fill: parent
active: root.currentIndex === 3
visible: active
focus: active
sourceComponent: DankBarTab { sourceComponent: DankBarTab {
parentModal: root.parentModal parentModal: root.parentModal
} }
@@ -79,7 +98,7 @@ FocusScope {
id: widgetsLoader id: widgetsLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 3 active: root.currentIndex === 4
visible: active visible: active
focus: active focus: active
@@ -96,7 +115,7 @@ FocusScope {
id: dockLoader id: dockLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 4 active: root.currentIndex === 5
visible: active visible: active
focus: active focus: active
@@ -115,7 +134,7 @@ FocusScope {
id: displaysLoader id: displaysLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 5 active: root.currentIndex === 6
visible: active visible: active
focus: active focus: active
@@ -132,7 +151,7 @@ FocusScope {
id: networkLoader id: networkLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 6 active: root.currentIndex === 7
visible: active visible: active
focus: active focus: active
@@ -149,7 +168,7 @@ FocusScope {
id: printerLoader id: printerLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 7 active: root.currentIndex === 8
visible: active visible: active
focus: active focus: active
@@ -166,7 +185,7 @@ FocusScope {
id: launcherLoader id: launcherLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 8 active: root.currentIndex === 9
visible: active visible: active
focus: active focus: active
@@ -183,7 +202,7 @@ FocusScope {
id: themeColorsLoader id: themeColorsLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 9 active: root.currentIndex === 10
visible: active visible: active
focus: active focus: active
@@ -200,7 +219,7 @@ FocusScope {
id: powerLoader id: powerLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 10 active: root.currentIndex === 11
visible: active visible: active
focus: active focus: active
@@ -217,7 +236,7 @@ FocusScope {
id: pluginsLoader id: pluginsLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 11 active: root.currentIndex === 12
visible: active visible: active
focus: active focus: active
@@ -236,7 +255,7 @@ FocusScope {
id: aboutLoader id: aboutLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 12 active: root.currentIndex === 13
visible: active visible: active
focus: active focus: active

View File

@@ -22,62 +22,68 @@ Rectangle {
"icon": "schedule", "icon": "schedule",
"tabIndex": 1 "tabIndex": 1
}, },
{
"text": I18n.tr("Keyboard Shortcuts"),
"icon": "keyboard",
"shortcutsOnly": true,
"tabIndex": 2
},
{ {
"text": I18n.tr("Dank Bar"), "text": I18n.tr("Dank Bar"),
"icon": "toolbar", "icon": "toolbar",
"tabIndex": 2 "tabIndex": 3
}, },
{ {
"text": I18n.tr("Widgets"), "text": I18n.tr("Widgets"),
"icon": "widgets", "icon": "widgets",
"tabIndex": 3 "tabIndex": 4
}, },
{ {
"text": I18n.tr("Dock"), "text": I18n.tr("Dock"),
"icon": "dock_to_bottom", "icon": "dock_to_bottom",
"tabIndex": 4 "tabIndex": 5
}, },
{ {
"text": I18n.tr("Displays"), "text": I18n.tr("Displays"),
"icon": "monitor", "icon": "monitor",
"tabIndex": 5 "tabIndex": 6
}, },
{ {
"text": I18n.tr("Network"), "text": I18n.tr("Network"),
"icon": "wifi", "icon": "wifi",
"dmsOnly": true, "dmsOnly": true,
"tabIndex": 6 "tabIndex": 7
}, },
{ {
"text": I18n.tr("Printers"), "text": I18n.tr("Printers"),
"icon": "print", "icon": "print",
"cupsOnly": true, "cupsOnly": true,
"tabIndex": 7 "tabIndex": 8
}, },
{ {
"text": I18n.tr("Launcher"), "text": I18n.tr("Launcher"),
"icon": "apps", "icon": "apps",
"tabIndex": 8 "tabIndex": 9
}, },
{ {
"text": I18n.tr("Theme & Colors"), "text": I18n.tr("Theme & Colors"),
"icon": "palette", "icon": "palette",
"tabIndex": 9 "tabIndex": 10
}, },
{ {
"text": I18n.tr("Power & Security"), "text": I18n.tr("Power & Security"),
"icon": "power", "icon": "power",
"tabIndex": 10 "tabIndex": 11
}, },
{ {
"text": I18n.tr("Plugins"), "text": I18n.tr("Plugins"),
"icon": "extension", "icon": "extension",
"tabIndex": 11 "tabIndex": 12
}, },
{ {
"text": I18n.tr("About"), "text": I18n.tr("About"),
"icon": "info", "icon": "info",
"tabIndex": 12 "tabIndex": 13
} }
] ]
readonly property var sidebarItems: allSidebarItems.filter(item => { readonly property var sidebarItems: allSidebarItems.filter(item => {
@@ -85,6 +91,8 @@ Rectangle {
return false; return false;
if (item.cupsOnly && !CupsService.cupsAvailable) if (item.cupsOnly && !CupsService.cupsAvailable)
return false; return false;
if (item.shortcutsOnly && !KeybindsService.available)
return false;
return true; return true;
}) })

View File

@@ -0,0 +1,548 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: keybindsTab
property var parentModal: null
property string selectedCategory: ""
property string searchQuery: ""
property string expandedKey: ""
property bool showingNewBind: false
property int _lastDataVersion: -1
property var _cachedCategories: []
property var _filteredBinds: []
function _updateFiltered() {
const allBinds = KeybindsService.getFlatBinds();
if (!searchQuery && !selectedCategory) {
_filteredBinds = allBinds;
return;
}
const q = searchQuery.toLowerCase();
const isOverrideFilter = selectedCategory === "__overrides__";
const result = [];
for (let i = 0; i < allBinds.length; i++) {
const group = allBinds[i];
if (q) {
let keyMatch = false;
for (let k = 0; k < group.keys.length; k++) {
if (group.keys[k].key.toLowerCase().indexOf(q) !== -1) {
keyMatch = true;
break;
}
}
if (!keyMatch && group.desc.toLowerCase().indexOf(q) === -1 && group.action.toLowerCase().indexOf(q) === -1)
continue;
}
if (isOverrideFilter) {
let hasOverride = false;
for (let k = 0; k < group.keys.length; k++) {
if (group.keys[k].isOverride) {
hasOverride = true;
break;
}
}
if (!hasOverride)
continue;
} else if (selectedCategory && group.category !== selectedCategory) {
continue;
}
result.push(group);
}
_filteredBinds = result;
}
function _updateCategories() {
_cachedCategories = ["__overrides__"].concat(KeybindsService.getCategories());
}
function getCategoryLabel(cat) {
if (cat === "__overrides__")
return I18n.tr("Overrides");
return cat;
}
function toggleExpanded(action) {
expandedKey = expandedKey === action ? "" : action;
}
function startNewBind() {
showingNewBind = true;
expandedKey = "";
}
function cancelNewBind() {
showingNewBind = false;
}
function saveNewBind(bindData) {
KeybindsService.saveBind("", bindData);
showingNewBind = false;
}
function scrollToTop() {
flickable.contentY = 0;
}
Timer {
id: searchDebounce
interval: 150
onTriggered: keybindsTab._updateFiltered()
}
Connections {
target: KeybindsService
function onBindsLoaded() {
keybindsTab._lastDataVersion = KeybindsService._dataVersion;
keybindsTab._updateCategories();
keybindsTab._updateFiltered();
}
}
function _ensureNiriProvider() {
if (!KeybindsService.available)
return;
const cachedProvider = KeybindsService.keybinds?.provider;
if (cachedProvider !== "niri" || KeybindsService._dataVersion === 0) {
KeybindsService.currentProvider = "niri";
KeybindsService.loadBinds();
return;
}
if (_lastDataVersion !== KeybindsService._dataVersion) {
_lastDataVersion = KeybindsService._dataVersion;
_updateCategories();
_updateFiltered();
}
}
Component.onCompleted: _ensureNiriProvider()
onVisibleChanged: {
if (!visible)
return;
Qt.callLater(scrollToTop);
_ensureNiriProvider();
}
DankFlickable {
id: flickable
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: contentColumn.implicitHeight
Column {
id: contentColumn
width: flickable.width
spacing: Theme.spacingL
topPadding: Theme.spacingXL
bottomPadding: Theme.spacingXL
StyledRect {
width: Math.min(650, parent.width - Theme.spacingL * 2)
height: headerSection.implicitHeight + Theme.spacingL * 2
anchors.horizontalCenter: parent.horizontalCenter
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.width: 0
Column {
id: headerSection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "keyboard"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - Theme.spacingM * 2
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Keyboard Shortcuts")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Click any shortcut to edit. Changes save to dms/binds.kdl")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
DankTextField {
id: searchField
width: parent.width - addButton.width - Theme.spacingM
height: 44
placeholderText: I18n.tr("Search keybinds...")
leftIconName: "search"
onTextChanged: {
keybindsTab.searchQuery = text;
searchDebounce.restart();
}
}
DankActionButton {
id: addButton
width: 44
height: 44
circular: false
iconName: "add"
iconSize: Theme.iconSize
iconColor: Theme.primary
anchors.verticalCenter: parent.verticalCenter
enabled: !keybindsTab.showingNewBind
opacity: enabled ? 1 : 0.5
onClicked: keybindsTab.startNewBind()
}
}
}
}
StyledRect {
width: Math.min(650, parent.width - Theme.spacingL * 2)
height: warningSection.implicitHeight + Theme.spacingL * 2
anchors.horizontalCenter: parent.horizontalCenter
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.error, 0.15)
border.color: Theme.withAlpha(Theme.error, 0.3)
border.width: 1
visible: !KeybindsService.dmsBindsIncluded && !KeybindsService.loading
Column {
id: warningSection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "warning"
size: Theme.iconSize
color: Theme.error
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - 100 - Theme.spacingM * 2
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Binds Include Missing")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.error
}
StyledText {
text: I18n.tr("dms/binds.kdl is not included in config.kdl. Custom keybinds will not work until this is fixed.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
}
}
Rectangle {
id: fixButton
width: fixButtonText.implicitWidth + Theme.spacingL * 2
height: 36
radius: Theme.cornerRadius
color: KeybindsService.fixing ? Theme.withAlpha(Theme.error, 0.6) : Theme.error
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: fixButtonText
text: KeybindsService.fixing ? I18n.tr("Fixing...") : I18n.tr("Fix Now")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surface
anchors.centerIn: parent
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
enabled: !KeybindsService.fixing
onClicked: KeybindsService.fixDmsBindsInclude()
}
}
}
}
}
StyledRect {
width: Math.min(650, parent.width - Theme.spacingL * 2)
height: categorySection.implicitHeight + Theme.spacingL * 2
anchors.horizontalCenter: parent.horizontalCenter
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.width: 0
Column {
id: categorySection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Flow {
width: parent.width
spacing: Theme.spacingS
Rectangle {
width: allChip.implicitWidth + Theme.spacingL
height: 32
radius: 16
color: !keybindsTab.selectedCategory ? Theme.primary : Theme.surfaceContainerHighest
StyledText {
id: allChip
text: I18n.tr("All")
font.pixelSize: Theme.fontSizeSmall
color: !keybindsTab.selectedCategory ? Theme.primaryText : Theme.surfaceVariantText
anchors.centerIn: parent
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
keybindsTab.selectedCategory = "";
keybindsTab._updateFiltered();
}
}
}
Repeater {
model: keybindsTab._cachedCategories
delegate: Rectangle {
required property string modelData
required property int index
width: catText.implicitWidth + Theme.spacingL
height: 32
radius: 16
color: keybindsTab.selectedCategory === modelData ? Theme.primary : (modelData === "__overrides__" ? Theme.withAlpha(Theme.primary, 0.15) : Theme.surfaceContainerHighest)
StyledText {
id: catText
text: keybindsTab.getCategoryLabel(modelData)
font.pixelSize: Theme.fontSizeSmall
color: keybindsTab.selectedCategory === modelData ? Theme.primaryText : (modelData === "__overrides__" ? Theme.primary : Theme.surfaceVariantText)
anchors.centerIn: parent
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
keybindsTab.selectedCategory = modelData;
keybindsTab._updateFiltered();
}
}
}
}
}
}
}
StyledRect {
width: Math.min(650, parent.width - Theme.spacingL * 2)
height: newBindSection.implicitHeight + Theme.spacingL * 2
anchors.horizontalCenter: parent.horizontalCenter
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.color: Theme.outlineVariant
border.width: 1
visible: keybindsTab.showingNewBind
Column {
id: newBindSection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "add"
size: Theme.iconSize
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("New Keybind")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
KeybindItem {
width: parent.width
isNew: true
isExpanded: true
bindData: ({
keys: [
{
key: "",
source: "dms",
isOverride: true
}
],
action: "",
desc: ""
})
panelWindow: keybindsTab.parentModal
onSaveBind: (originalKey, newData) => keybindsTab.saveNewBind(newData)
onCancelEdit: keybindsTab.cancelNewBind()
}
}
}
StyledRect {
width: Math.min(650, parent.width - Theme.spacingL * 2)
height: bindsListHeader.implicitHeight + Theme.spacingL * 2
anchors.horizontalCenter: parent.horizontalCenter
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.width: 0
Column {
id: bindsListHeader
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "list"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: KeybindsService.loading ? I18n.tr("Shortcuts") : I18n.tr("Shortcuts") + " (" + keybindsTab._filteredBinds.length + ")"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: KeybindsService.loading
DankIcon {
id: loadingIcon
name: "sync"
size: 20
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
running: KeybindsService.loading
}
}
StyledText {
text: I18n.tr("Loading keybinds...")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
text: I18n.tr("No keybinds found")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: !KeybindsService.loading && keybindsTab._filteredBinds.length === 0
}
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: ScriptModel {
values: keybindsTab._filteredBinds
objectProp: "action"
}
delegate: Item {
required property var modelData
required property int index
width: parent.width
height: bindItem.height
KeybindItem {
id: bindItem
width: Math.min(650, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
bindData: modelData
isExpanded: keybindsTab.expandedKey === modelData.action
panelWindow: keybindsTab.parentModal
onToggleExpand: keybindsTab.toggleExpanded(modelData.action)
onSaveBind: (originalKey, newData) => {
KeybindsService.saveBind(originalKey, newData);
keybindsTab.expandedKey = modelData.action;
}
onRemoveBind: key => KeybindsService.removeBind(key)
}
}
}
}
}
}
}

View File

@@ -2,8 +2,6 @@ import QtQuick
import QtQuick.Effects import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Widgets
import Quickshell.Io
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
@@ -22,13 +20,13 @@ PanelWindow {
target: ToastService target: ToastService
function onToastVisibleChanged() { function onToastVisibleChanged() {
if (ToastService.toastVisible) { if (ToastService.toastVisible) {
shouldBeVisible = true shouldBeVisible = true;
visible = true visible = true;
} else { } else {
// Freeze the width before starting exit animation // Freeze the width before starting exit animation
frozenWidth = toast.width frozenWidth = toast.width;
shouldBeVisible = false shouldBeVisible = false;
closeTimer.restart() closeTimer.restart();
} }
} }
} }
@@ -38,7 +36,7 @@ PanelWindow {
interval: Theme.mediumDuration + 50 interval: Theme.mediumDuration + 50
onTriggered: { onTriggered: {
if (!shouldBeVisible) { if (!shouldBeVisible) {
visible = false visible = false;
} }
} }
} }
@@ -65,7 +63,7 @@ PanelWindow {
Connections { Connections {
target: ToastService target: ToastService
function onResetToastState() { function onResetToastState() {
toast.expanded = false toast.expanded = false;
} }
} }
@@ -76,13 +74,13 @@ PanelWindow {
color: { color: {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
return Theme.error return Theme.error;
case ToastService.levelWarn: case ToastService.levelWarn:
return Theme.warning return Theme.warning;
case ToastService.levelInfo: case ToastService.levelInfo:
return Theme.surfaceContainer return Theme.surfaceContainer;
default: default:
return Theme.surfaceContainer return Theme.surfaceContainer;
} }
} }
radius: Theme.cornerRadius radius: Theme.cornerRadius
@@ -109,13 +107,13 @@ PanelWindow {
name: { name: {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
return "error" return "error";
case ToastService.levelWarn: case ToastService.levelWarn:
return "warning" return "warning";
case ToastService.levelInfo: case ToastService.levelInfo:
return "info" return "info";
default: default:
return "info" return "info";
} }
} }
size: Theme.iconSize size: Theme.iconSize
@@ -123,9 +121,9 @@ PanelWindow {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
case ToastService.levelWarn: case ToastService.levelWarn:
return SessionData.isLightMode ? Theme.surfaceText : Theme.background return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
default: default:
return Theme.surfaceText return Theme.surfaceText;
} }
} }
anchors.left: parent.left anchors.left: parent.left
@@ -140,9 +138,9 @@ PanelWindow {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
case ToastService.levelWarn: case ToastService.levelWarn:
return SessionData.isLightMode ? Theme.surfaceText : Theme.background return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
default: default:
return Theme.surfaceText return Theme.surfaceText;
} }
} }
font.weight: Font.Medium font.weight: Font.Medium
@@ -163,9 +161,9 @@ PanelWindow {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
case ToastService.levelWarn: case ToastService.levelWarn:
return SessionData.isLightMode ? Theme.surfaceText : Theme.background return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
default: default:
return Theme.surfaceText return Theme.surfaceText;
} }
} }
buttonSize: Theme.iconSize + 8 buttonSize: Theme.iconSize + 8
@@ -175,11 +173,11 @@ PanelWindow {
visible: ToastService.hasDetails visible: ToastService.hasDetails
onClicked: { onClicked: {
toast.expanded = !toast.expanded toast.expanded = !toast.expanded;
if (toast.expanded) { if (toast.expanded) {
ToastService.stopTimer() ToastService.stopTimer();
} else { } else {
ToastService.restartTimer() ToastService.restartTimer();
} }
} }
} }
@@ -192,9 +190,9 @@ PanelWindow {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
case ToastService.levelWarn: case ToastService.levelWarn:
return SessionData.isLightMode ? Theme.surfaceText : Theme.background return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
default: default:
return Theme.surfaceText return Theme.surfaceText;
} }
} }
buttonSize: Theme.iconSize + 8 buttonSize: Theme.iconSize + 8
@@ -203,7 +201,7 @@ PanelWindow {
visible: ToastService.hasDetails || ToastService.currentLevel === ToastService.levelError visible: ToastService.hasDetails || ToastService.currentLevel === ToastService.levelError
onClicked: { onClicked: {
ToastService.hideToast() ToastService.hideToast();
} }
} }
} }
@@ -211,7 +209,7 @@ PanelWindow {
Rectangle { Rectangle {
width: parent.width width: parent.width
height: detailsColumn.height + Theme.spacingS * 2 height: detailsColumn.height + Theme.spacingS * 2
color: Qt.rgba(0, 0, 0, 0.2) color: ToastService.currentDetails.length > 0 ? Qt.rgba(0, 0, 0, 0.2) : "transparent"
radius: Theme.cornerRadius / 2 radius: Theme.cornerRadius / 2
visible: toast.expanded && ToastService.hasDetails visible: toast.expanded && ToastService.hasDetails
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
@@ -232,9 +230,9 @@ PanelWindow {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
case ToastService.levelWarn: case ToastService.levelWarn:
return SessionData.isLightMode ? Theme.surfaceText : Theme.background return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
default: default:
return Theme.surfaceText return Theme.surfaceText;
} }
} }
visible: ToastService.currentDetails.length > 0 visible: ToastService.currentDetails.length > 0
@@ -259,9 +257,9 @@ PanelWindow {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
case ToastService.levelWarn: case ToastService.levelWarn:
return SessionData.isLightMode ? Theme.surfaceText : Theme.background return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
default: default:
return Theme.surfaceText return Theme.surfaceText;
} }
} }
isMonospace: true isMonospace: true
@@ -281,9 +279,9 @@ PanelWindow {
switch (ToastService.currentLevel) { switch (ToastService.currentLevel) {
case ToastService.levelError: case ToastService.levelError:
case ToastService.levelWarn: case ToastService.levelWarn:
return SessionData.isLightMode ? Theme.surfaceText : Theme.background return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
default: default:
return Theme.surfaceText return Theme.surfaceText;
} }
} }
buttonSize: Theme.iconSizeSmall + 8 buttonSize: Theme.iconSizeSmall + 8
@@ -295,9 +293,9 @@ PanelWindow {
property bool showTooltip: false property bool showTooltip: false
onClicked: { onClicked: {
Quickshell.execDetached(["wl-copy", ToastService.currentCommand]) Quickshell.execDetached(["wl-copy", ToastService.currentCommand]);
showTooltip = true showTooltip = true;
tooltipTimer.start() tooltipTimer.start();
} }
Timer { Timer {
@@ -346,7 +344,6 @@ PanelWindow {
shadowOpacity: 0.3 shadowOpacity: 0.3
} }
Behavior on opacity { Behavior on opacity {
NumberAnimation { NumberAnimation {
duration: Theme.mediumDuration duration: Theme.mediumDuration

View File

@@ -422,7 +422,6 @@ Singleton {
isLabwc = false; isLabwc = false;
compositor = "niri"; compositor = "niri";
console.info("CompositorService: Detected Niri with socket:", niriSocket); console.info("CompositorService: Detected Niri with socket:", niriSocket);
NiriService.generateNiriBinds();
NiriService.generateNiriBlurrule(); NiriService.generateNiriBlurrule();
} }
}, 0); }, 0);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +0,0 @@
binds {
Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle";
}
Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle";
}
Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "toggle";
}
Mod+N hotkey-overlay-title="Notification Center" {
spawn "dms" "ipc" "call" "notifications" "toggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
}
Mod+Shift+N hotkey-overlay-title="Notepad" {
spawn "dms" "ipc" "call" "notepad" "toggle";
}
Mod+Alt+L hotkey-overlay-title="Lock Screen" {
spawn "dms" "ipc" "call" "lock" "lock";
}
Ctrl+Alt+Delete hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
// Audio
XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "increment" "3";
}
XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "decrement" "3";
}
XF86AudioMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "mute";
}
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
// BL
XF86MonBrightnessUp allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "increment" "5" "";
}
XF86MonBrightnessDown allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "decrement" "5" "";
}
}

File diff suppressed because it is too large Load Diff