mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-29 16:02:51 -05:00
niri: add window-rule management
- settings UI for creating, editing, deleting window ruels - IPC to create a window rule for the currently focused toplevel fixes #1292
This commit is contained in:
@@ -8,6 +8,7 @@ This file is more of a quick reference so I know what to account for before next
|
|||||||
- launcher actions, customize env, args, name, icon
|
- launcher actions, customize env, args, name, icon
|
||||||
- launcher v2 - omega stuff, GIF search, supa powerful
|
- launcher v2 - omega stuff, GIF search, supa powerful
|
||||||
- dock on bar
|
- dock on bar
|
||||||
|
- window rule manager, with IPC - #TODO verify RTL layout (niri only)
|
||||||
|
|
||||||
# 1.2.0
|
# 1.2.0
|
||||||
|
|
||||||
|
|||||||
336
core/cmd/dms/commands_windowrules.go
Normal file
336
core/cmd/dms/commands_windowrules.go
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var windowrulesCmd = &cobra.Command{
|
||||||
|
Use: "windowrules",
|
||||||
|
Short: "Manage window rules",
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesListCmd = &cobra.Command{
|
||||||
|
Use: "list [compositor]",
|
||||||
|
Short: "List all window rules",
|
||||||
|
Long: "List all window rules from compositor config file. Returns JSON with rules and DMS status.",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesAddCmd = &cobra.Command{
|
||||||
|
Use: "add <compositor> '<json>'",
|
||||||
|
Short: "Add a window rule to DMS file",
|
||||||
|
Long: "Add a new window rule to the DMS-managed rules file.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesAdd,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <compositor> <id> '<json>'",
|
||||||
|
Short: "Update a window rule in DMS file",
|
||||||
|
Long: "Update an existing window rule in the DMS-managed rules file.",
|
||||||
|
Args: cobra.ExactArgs(3),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <compositor> <id>",
|
||||||
|
Short: "Remove a window rule from DMS file",
|
||||||
|
Long: "Remove a window rule from the DMS-managed rules file.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesRemove,
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowrulesReorderCmd = &cobra.Command{
|
||||||
|
Use: "reorder <compositor> '<json-array-of-ids>'",
|
||||||
|
Short: "Reorder window rules in DMS file",
|
||||||
|
Long: "Reorder window rules by providing a JSON array of rule IDs in the desired order.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: runWindowrulesReorder,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
configCmd.AddCommand(windowrulesCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesListCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesAddCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesUpdateCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesRemoveCmd)
|
||||||
|
windowrulesCmd.AddCommand(windowrulesReorderCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowRulesListResult struct {
|
||||||
|
Rules []windowrules.WindowRule `json:"rules"`
|
||||||
|
DMSStatus *windowrules.DMSRulesStatus `json:"dmsStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowRuleWriteResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCompositor(args []string) string {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return strings.ToLower(args[0])
|
||||||
|
}
|
||||||
|
if os.Getenv("NIRI_SOCKET") != "" {
|
||||||
|
return "niri"
|
||||||
|
}
|
||||||
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||||
|
return "hyprland"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRuleError(errMsg string) {
|
||||||
|
result := WindowRuleWriteResult{Success: false, Error: errMsg}
|
||||||
|
output, _ := json.Marshal(result)
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRuleSuccess(id, path string) {
|
||||||
|
result := WindowRuleWriteResult{Success: true, ID: id, Path: path}
|
||||||
|
output, _ := json.Marshal(result)
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := getCompositor(args)
|
||||||
|
if compositor == "" {
|
||||||
|
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result WindowRulesListResult
|
||||||
|
|
||||||
|
switch compositor {
|
||||||
|
case "niri":
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to expand niri config path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResult, err := providers.ParseNiriWindowRules(configDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to parse niri window rules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allRules := providers.ConvertNiriRulesToWindowRules(parseResult.Rules)
|
||||||
|
|
||||||
|
provider := providers.NewNiriWritableProvider(configDir)
|
||||||
|
dmsRulesPath := provider.GetOverridePath()
|
||||||
|
dmsRules, _ := provider.LoadDMSRules()
|
||||||
|
|
||||||
|
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||||
|
for i, dr := range dmsRules {
|
||||||
|
dmsRuleMap[i] = dr
|
||||||
|
}
|
||||||
|
|
||||||
|
dmsIdx := 0
|
||||||
|
for i, r := range allRules {
|
||||||
|
if r.Source == dmsRulesPath {
|
||||||
|
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||||
|
allRules[i].ID = dmr.ID
|
||||||
|
allRules[i].Name = dmr.Name
|
||||||
|
}
|
||||||
|
dmsIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Rules = allRules
|
||||||
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
|
case "hyprland":
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to expand hyprland config path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to parse hyprland window rules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allRules := providers.ConvertHyprlandRulesToWindowRules(parseResult.Rules)
|
||||||
|
|
||||||
|
provider := providers.NewHyprlandWritableProvider(configDir)
|
||||||
|
dmsRulesPath := provider.GetOverridePath()
|
||||||
|
dmsRules, _ := provider.LoadDMSRules()
|
||||||
|
|
||||||
|
dmsRuleMap := make(map[int]windowrules.WindowRule)
|
||||||
|
for i, dr := range dmsRules {
|
||||||
|
dmsRuleMap[i] = dr
|
||||||
|
}
|
||||||
|
|
||||||
|
dmsIdx := 0
|
||||||
|
for i, r := range allRules {
|
||||||
|
if r.Source == dmsRulesPath {
|
||||||
|
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
|
||||||
|
allRules[i].ID = dmr.ID
|
||||||
|
allRules[i].Name = dmr.Name
|
||||||
|
}
|
||||||
|
dmsIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Rules = allRules
|
||||||
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown compositor: %s", compositor)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := json.Marshal(result)
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesAdd(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
ruleJSON := args[1]
|
||||||
|
|
||||||
|
var rule windowrules.WindowRule
|
||||||
|
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.ID == "" {
|
||||||
|
rule.ID = generateRuleID()
|
||||||
|
}
|
||||||
|
rule.Enabled = true
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess(rule.ID, provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesUpdate(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
ruleID := args[1]
|
||||||
|
ruleJSON := args[2]
|
||||||
|
|
||||||
|
var rule windowrules.WindowRule
|
||||||
|
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.ID = ruleID
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess(rule.ID, provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesRemove(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
ruleID := args[1]
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.RemoveRule(ruleID); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess(ruleID, provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowrulesReorder(cmd *cobra.Command, args []string) {
|
||||||
|
compositor := strings.ToLower(args[0])
|
||||||
|
idsJSON := args[1]
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
if err := json.Unmarshal([]byte(idsJSON), &ids); err != nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Invalid JSON array: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := getWindowRulesProvider(compositor)
|
||||||
|
if provider == nil {
|
||||||
|
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.ReorderRules(ids); err != nil {
|
||||||
|
writeRuleError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRuleSuccess("", provider.GetOverridePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
|
||||||
|
switch compositor {
|
||||||
|
case "niri":
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return providers.NewNiriWritableProvider(configDir)
|
||||||
|
case "hyprland":
|
||||||
|
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return providers.NewHyprlandWritableProvider(configDir)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRuleID() string {
|
||||||
|
return fmt.Sprintf("wr_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
@@ -211,6 +211,7 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
|
|||||||
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||||
{"outputs.kdl", ""},
|
{"outputs.kdl", ""},
|
||||||
{"cursor.kdl", ""},
|
{"cursor.kdl", ""},
|
||||||
|
{"windowrules.kdl", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
@@ -563,6 +564,7 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
|
|||||||
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||||
{"outputs.conf", ""},
|
{"outputs.conf", ""},
|
||||||
{"cursor.conf", ""},
|
{"cursor.conf", ""},
|
||||||
|
{"windowrules.conf", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ bind = SUPER, F, fullscreen, 1
|
|||||||
bind = SUPER SHIFT, F, fullscreen, 0
|
bind = SUPER SHIFT, F, fullscreen, 0
|
||||||
bind = SUPER SHIFT, T, togglefloating
|
bind = SUPER SHIFT, T, togglefloating
|
||||||
bind = SUPER, W, togglegroup
|
bind = SUPER, W, togglegroup
|
||||||
|
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
|
||||||
|
|
||||||
# === Focus Navigation ===
|
# === Focus Navigation ===
|
||||||
bind = SUPER, left, movefocus, l
|
bind = SUPER, left, movefocus, l
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ binds {
|
|||||||
Mod+Shift+T { toggle-window-floating; }
|
Mod+Shift+T { toggle-window-floating; }
|
||||||
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
|
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
|
||||||
Mod+W { toggle-column-tabbed-display; }
|
Mod+W { toggle-column-tabbed-display; }
|
||||||
|
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
|
||||||
|
|
||||||
// === Focus Navigation ===
|
// === Focus Navigation ===
|
||||||
Mod+Left { focus-column-left; }
|
Mod+Left { focus-column-left; }
|
||||||
|
|||||||
658
core/internal/windowrules/providers/hyprland_parser.go
Normal file
658
core/internal/windowrules/providers/hyprland_parser.go
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HyprlandWindowRule struct {
|
||||||
|
MatchClass string
|
||||||
|
MatchTitle string
|
||||||
|
MatchXWayland *bool
|
||||||
|
MatchFloating *bool
|
||||||
|
MatchFullscreen *bool
|
||||||
|
MatchPinned *bool
|
||||||
|
MatchInitialised *bool
|
||||||
|
Rule string
|
||||||
|
Value string
|
||||||
|
Source string
|
||||||
|
RawLine string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HyprlandRulesParser struct {
|
||||||
|
configDir string
|
||||||
|
processedFiles map[string]bool
|
||||||
|
rules []HyprlandWindowRule
|
||||||
|
currentSource string
|
||||||
|
dmsRulesExists bool
|
||||||
|
dmsRulesIncluded bool
|
||||||
|
includeCount int
|
||||||
|
dmsIncludePos int
|
||||||
|
rulesAfterDMS int
|
||||||
|
dmsProcessed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHyprlandRulesParser(configDir string) *HyprlandRulesParser {
|
||||||
|
return &HyprlandRulesParser{
|
||||||
|
configDir: configDir,
|
||||||
|
processedFiles: make(map[string]bool),
|
||||||
|
rules: []HyprlandWindowRule{},
|
||||||
|
dmsIncludePos: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
|
||||||
|
expandedDir, err := utils.ExpandPath(p.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dmsRulesPath := filepath.Join(expandedDir, "dms", "windowrules.conf")
|
||||||
|
if _, err := os.Stat(dmsRulesPath); err == nil {
|
||||||
|
p.dmsRulesExists = true
|
||||||
|
}
|
||||||
|
|
||||||
|
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
|
||||||
|
if err := p.parseFile(mainConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.dmsRulesExists && !p.dmsProcessed {
|
||||||
|
p.parseDMSRulesDirectly(dmsRulesPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
|
||||||
|
data, err := os.ReadFile(dmsRulesPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSource := p.currentSource
|
||||||
|
p.currentSource = dmsRulesPath
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
p.parseLine(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.currentSource = prevSource
|
||||||
|
p.dmsProcessed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) parseFile(filePath string) error {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.processedFiles[absPath] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.processedFiles[absPath] = true
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSource := p.currentSource
|
||||||
|
p.currentSource = absPath
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "source") {
|
||||||
|
p.handleSource(trimmed, filepath.Dir(absPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
p.parseLine(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.currentSource = prevSource
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) handleSource(line string, baseDir string) {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := strings.TrimSpace(parts[1])
|
||||||
|
isDMSSource := sourcePath == "dms/windowrules.conf" || strings.HasSuffix(sourcePath, "/dms/windowrules.conf")
|
||||||
|
|
||||||
|
p.includeCount++
|
||||||
|
if isDMSSource {
|
||||||
|
p.dmsRulesIncluded = true
|
||||||
|
p.dmsIncludePos = p.includeCount
|
||||||
|
p.dmsProcessed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := sourcePath
|
||||||
|
if !filepath.IsAbs(sourcePath) {
|
||||||
|
fullPath = filepath.Join(baseDir, sourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded, err := utils.ExpandPath(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = p.parseFile(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) parseLine(line string) {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "windowrule") {
|
||||||
|
rule := p.parseWindowRuleLine(trimmed)
|
||||||
|
if rule != nil {
|
||||||
|
rule.Source = p.currentSource
|
||||||
|
p.rules = append(p.rules, *rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowRuleV2Regex = regexp.MustCompile(`^windowrulev?2?\s*=\s*(.+)$`)
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) parseWindowRuleLine(line string) *HyprlandWindowRule {
|
||||||
|
matches := windowRuleV2Regex.FindStringSubmatch(line)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(matches[1])
|
||||||
|
isV2 := strings.HasPrefix(line, "windowrulev2")
|
||||||
|
|
||||||
|
rule := &HyprlandWindowRule{
|
||||||
|
RawLine: line,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isV2 {
|
||||||
|
p.parseWindowRuleV2(content, rule)
|
||||||
|
} else {
|
||||||
|
p.parseWindowRuleV1(content, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) parseWindowRuleV1(content string, rule *HyprlandWindowRule) {
|
||||||
|
parts := strings.SplitN(content, ",", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.Rule = strings.TrimSpace(parts[0])
|
||||||
|
rule.MatchClass = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) parseWindowRuleV2(content string, rule *HyprlandWindowRule) {
|
||||||
|
parts := strings.SplitN(content, ",", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleAndValue := strings.TrimSpace(parts[0])
|
||||||
|
matchPart := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
if idx := strings.Index(ruleAndValue, " "); idx > 0 {
|
||||||
|
rule.Rule = ruleAndValue[:idx]
|
||||||
|
rule.Value = strings.TrimSpace(ruleAndValue[idx+1:])
|
||||||
|
} else {
|
||||||
|
rule.Rule = ruleAndValue
|
||||||
|
}
|
||||||
|
|
||||||
|
matchPairs := strings.Split(matchPart, ",")
|
||||||
|
for _, pair := range matchPairs {
|
||||||
|
pair = strings.TrimSpace(pair)
|
||||||
|
if colonIdx := strings.Index(pair, ":"); colonIdx > 0 {
|
||||||
|
key := strings.TrimSpace(pair[:colonIdx])
|
||||||
|
value := strings.TrimSpace(pair[colonIdx+1:])
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "class":
|
||||||
|
rule.MatchClass = value
|
||||||
|
case "title":
|
||||||
|
rule.MatchTitle = value
|
||||||
|
case "xwayland":
|
||||||
|
b := value == "1" || value == "true"
|
||||||
|
rule.MatchXWayland = &b
|
||||||
|
case "floating":
|
||||||
|
b := value == "1" || value == "true"
|
||||||
|
rule.MatchFloating = &b
|
||||||
|
case "fullscreen":
|
||||||
|
b := value == "1" || value == "true"
|
||||||
|
rule.MatchFullscreen = &b
|
||||||
|
case "pinned":
|
||||||
|
b := value == "1" || value == "true"
|
||||||
|
rule.MatchPinned = &b
|
||||||
|
case "initialised", "initialized":
|
||||||
|
b := value == "1" || value == "true"
|
||||||
|
rule.MatchInitialised = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) HasDMSRulesIncluded() bool {
|
||||||
|
return p.dmsRulesIncluded
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
|
||||||
|
status := &windowrules.DMSRulesStatus{
|
||||||
|
Exists: p.dmsRulesExists,
|
||||||
|
Included: p.dmsRulesIncluded,
|
||||||
|
IncludePosition: p.dmsIncludePos,
|
||||||
|
TotalIncludes: p.includeCount,
|
||||||
|
RulesAfterDMS: p.rulesAfterDMS,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !p.dmsRulesExists:
|
||||||
|
status.Effective = false
|
||||||
|
status.StatusMessage = "dms/windowrules.conf does not exist"
|
||||||
|
case !p.dmsRulesIncluded:
|
||||||
|
status.Effective = false
|
||||||
|
status.StatusMessage = "dms/windowrules.conf is not sourced in config"
|
||||||
|
case p.rulesAfterDMS > 0:
|
||||||
|
status.Effective = true
|
||||||
|
status.OverriddenBy = p.rulesAfterDMS
|
||||||
|
status.StatusMessage = "Some DMS rules may be overridden by config rules"
|
||||||
|
default:
|
||||||
|
status.Effective = true
|
||||||
|
status.StatusMessage = "DMS window rules are active"
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
type HyprlandRulesParseResult struct {
|
||||||
|
Rules []HyprlandWindowRule
|
||||||
|
DMSRulesIncluded bool
|
||||||
|
DMSStatus *windowrules.DMSRulesStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseHyprlandWindowRules(configDir string) (*HyprlandRulesParseResult, error) {
|
||||||
|
parser := NewHyprlandRulesParser(configDir)
|
||||||
|
rules, err := parser.Parse()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &HyprlandRulesParseResult{
|
||||||
|
Rules: rules,
|
||||||
|
DMSRulesIncluded: parser.HasDMSRulesIncluded(),
|
||||||
|
DMSStatus: parser.buildDMSStatus(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) {
|
||||||
|
t := true
|
||||||
|
switch rule {
|
||||||
|
case "float":
|
||||||
|
actions.OpenFloating = &t
|
||||||
|
case "tile":
|
||||||
|
actions.Tile = &t
|
||||||
|
case "fullscreen":
|
||||||
|
actions.OpenFullscreen = &t
|
||||||
|
case "maximize":
|
||||||
|
actions.OpenMaximized = &t
|
||||||
|
case "nofocus":
|
||||||
|
actions.NoFocus = &t
|
||||||
|
case "noborder":
|
||||||
|
actions.NoBorder = &t
|
||||||
|
case "noshadow":
|
||||||
|
actions.NoShadow = &t
|
||||||
|
case "nodim":
|
||||||
|
actions.NoDim = &t
|
||||||
|
case "noblur":
|
||||||
|
actions.NoBlur = &t
|
||||||
|
case "noanim":
|
||||||
|
actions.NoAnim = &t
|
||||||
|
case "norounding":
|
||||||
|
actions.NoRounding = &t
|
||||||
|
case "pin":
|
||||||
|
actions.Pin = &t
|
||||||
|
case "opaque":
|
||||||
|
actions.Opaque = &t
|
||||||
|
case "forcergbx":
|
||||||
|
actions.ForcergbX = &t
|
||||||
|
case "opacity":
|
||||||
|
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
actions.Opacity = &f
|
||||||
|
}
|
||||||
|
case "size":
|
||||||
|
actions.Size = value
|
||||||
|
case "move":
|
||||||
|
actions.Move = value
|
||||||
|
case "monitor":
|
||||||
|
actions.Monitor = value
|
||||||
|
case "workspace":
|
||||||
|
actions.Workspace = value
|
||||||
|
case "idleinhibit":
|
||||||
|
actions.Idleinhibit = value
|
||||||
|
case "rounding":
|
||||||
|
if i, err := strconv.Atoi(value); err == nil {
|
||||||
|
actions.CornerRadius = &i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertHyprlandRulesToWindowRules(hyprRules []HyprlandWindowRule) []windowrules.WindowRule {
|
||||||
|
result := make([]windowrules.WindowRule, 0, len(hyprRules))
|
||||||
|
for i, hr := range hyprRules {
|
||||||
|
wr := windowrules.WindowRule{
|
||||||
|
ID: strconv.Itoa(i),
|
||||||
|
Enabled: true,
|
||||||
|
Source: hr.Source,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: hr.MatchClass,
|
||||||
|
Title: hr.MatchTitle,
|
||||||
|
XWayland: hr.MatchXWayland,
|
||||||
|
IsFloating: hr.MatchFloating,
|
||||||
|
Fullscreen: hr.MatchFullscreen,
|
||||||
|
Pinned: hr.MatchPinned,
|
||||||
|
Initialised: hr.MatchInitialised,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
applyHyprlandRuleAction(&wr.Actions, hr.Rule, hr.Value)
|
||||||
|
result = append(result, wr)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type HyprlandWritableProvider struct {
|
||||||
|
configDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHyprlandWritableProvider(configDir string) *HyprlandWritableProvider {
|
||||||
|
return &HyprlandWritableProvider{configDir: configDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) Name() string {
|
||||||
|
return "hyprland"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) GetOverridePath() string {
|
||||||
|
expanded, _ := utils.ExpandPath(p.configDir)
|
||||||
|
return filepath.Join(expanded, "dms", "windowrules.conf")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
|
||||||
|
result, err := ParseHyprlandWindowRules(p.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &windowrules.RuleSet{
|
||||||
|
Title: "Hyprland Window Rules",
|
||||||
|
Provider: "hyprland",
|
||||||
|
Rules: ConvertHyprlandRulesToWindowRules(result.Rules),
|
||||||
|
DMSRulesIncluded: result.DMSRulesIncluded,
|
||||||
|
DMSStatus: result.DMSStatus,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
rules = []windowrules.WindowRule{}
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, r := range rules {
|
||||||
|
if r.ID == rule.ID {
|
||||||
|
rules[i] = rule
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.writeDMSRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) RemoveRule(id string) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newRules := make([]windowrules.WindowRule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.ID != id {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.writeDMSRules(newRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleMap := make(map[string]windowrules.WindowRule)
|
||||||
|
for _, r := range rules {
|
||||||
|
ruleMap[r.ID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
newRules := make([]windowrules.WindowRule, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if r, ok := ruleMap[id]; ok {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
delete(ruleMap, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range ruleMap {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.writeDMSRules(newRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
|
||||||
|
rulesPath := p.GetOverridePath()
|
||||||
|
data, err := os.ReadFile(rulesPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []windowrules.WindowRule{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []windowrules.WindowRule
|
||||||
|
var currentID, currentName string
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if matches := dmsRuleCommentRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||||
|
currentID = matches[1]
|
||||||
|
currentName = matches[2]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "windowrulev2") {
|
||||||
|
parser := NewHyprlandRulesParser(p.configDir)
|
||||||
|
hrule := parser.parseWindowRuleLine(trimmed)
|
||||||
|
if hrule == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wr := windowrules.WindowRule{
|
||||||
|
ID: currentID,
|
||||||
|
Name: currentName,
|
||||||
|
Enabled: true,
|
||||||
|
Source: rulesPath,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: hrule.MatchClass,
|
||||||
|
Title: hrule.MatchTitle,
|
||||||
|
XWayland: hrule.MatchXWayland,
|
||||||
|
IsFloating: hrule.MatchFloating,
|
||||||
|
Fullscreen: hrule.MatchFullscreen,
|
||||||
|
Pinned: hrule.MatchPinned,
|
||||||
|
Initialised: hrule.MatchInitialised,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
applyHyprlandRuleAction(&wr.Actions, hrule.Rule, hrule.Value)
|
||||||
|
|
||||||
|
if wr.ID == "" {
|
||||||
|
wr.ID = hrule.MatchClass
|
||||||
|
if wr.ID == "" {
|
||||||
|
wr.ID = hrule.MatchTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rules = append(rules, wr)
|
||||||
|
currentID = ""
|
||||||
|
currentName = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
|
||||||
|
rulesPath := p.GetOverridePath()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, "# DMS Window Rules - Managed by DankMaterialShell")
|
||||||
|
lines = append(lines, "# Do not edit manually - changes may be overwritten")
|
||||||
|
lines = append(lines, "")
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
lines = append(lines, p.formatRuleLines(rule)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandWritableProvider) formatRuleLines(rule windowrules.WindowRule) []string {
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("# DMS-RULE: id=%s, name=%s", rule.ID, rule.Name))
|
||||||
|
|
||||||
|
var matchParts []string
|
||||||
|
if rule.MatchCriteria.AppID != "" {
|
||||||
|
matchParts = append(matchParts, fmt.Sprintf("class:%s", rule.MatchCriteria.AppID))
|
||||||
|
}
|
||||||
|
if rule.MatchCriteria.Title != "" {
|
||||||
|
matchParts = append(matchParts, fmt.Sprintf("title:%s", rule.MatchCriteria.Title))
|
||||||
|
}
|
||||||
|
if rule.MatchCriteria.XWayland != nil {
|
||||||
|
matchParts = append(matchParts, fmt.Sprintf("xwayland:%d", boolToInt(*rule.MatchCriteria.XWayland)))
|
||||||
|
}
|
||||||
|
if rule.MatchCriteria.IsFloating != nil {
|
||||||
|
matchParts = append(matchParts, fmt.Sprintf("floating:%d", boolToInt(*rule.MatchCriteria.IsFloating)))
|
||||||
|
}
|
||||||
|
if rule.MatchCriteria.Fullscreen != nil {
|
||||||
|
matchParts = append(matchParts, fmt.Sprintf("fullscreen:%d", boolToInt(*rule.MatchCriteria.Fullscreen)))
|
||||||
|
}
|
||||||
|
if rule.MatchCriteria.Pinned != nil {
|
||||||
|
matchParts = append(matchParts, fmt.Sprintf("pinned:%d", boolToInt(*rule.MatchCriteria.Pinned)))
|
||||||
|
}
|
||||||
|
|
||||||
|
matchStr := strings.Join(matchParts, ", ")
|
||||||
|
a := rule.Actions
|
||||||
|
|
||||||
|
if a.OpenFloating != nil && *a.OpenFloating {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = float, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.Tile != nil && *a.Tile {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = tile, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.OpenFullscreen != nil && *a.OpenFullscreen {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = fullscreen, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.OpenMaximized != nil && *a.OpenMaximized {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = maximize, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.NoFocus != nil && *a.NoFocus {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = nofocus, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.NoBorder != nil && *a.NoBorder {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = noborder, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.NoShadow != nil && *a.NoShadow {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = noshadow, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.NoDim != nil && *a.NoDim {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = nodim, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.NoBlur != nil && *a.NoBlur {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = noblur, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.NoAnim != nil && *a.NoAnim {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = noanim, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.NoRounding != nil && *a.NoRounding {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = norounding, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.Pin != nil && *a.Pin {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = pin, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.Opaque != nil && *a.Opaque {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = opaque, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.ForcergbX != nil && *a.ForcergbX {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = forcergbx, %s", matchStr))
|
||||||
|
}
|
||||||
|
if a.Opacity != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = opacity %.2f, %s", *a.Opacity, matchStr))
|
||||||
|
}
|
||||||
|
if a.Size != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = size %s, %s", a.Size, matchStr))
|
||||||
|
}
|
||||||
|
if a.Move != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = move %s, %s", a.Move, matchStr))
|
||||||
|
}
|
||||||
|
if a.Monitor != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = monitor %s, %s", a.Monitor, matchStr))
|
||||||
|
}
|
||||||
|
if a.Workspace != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = workspace %s, %s", a.Workspace, matchStr))
|
||||||
|
}
|
||||||
|
if a.CornerRadius != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = rounding %d, %s", *a.CornerRadius, matchStr))
|
||||||
|
}
|
||||||
|
if a.Idleinhibit != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("windowrulev2 = idleinhibit %s, %s", a.Idleinhibit, matchStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 1 {
|
||||||
|
lines = append(lines, fmt.Sprintf("# (no actions defined for rule %s)", rule.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, "")
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
280
core/internal/windowrules/providers/hyprland_parser_test.go
Normal file
280
core/internal/windowrules/providers/hyprland_parser_test.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseWindowRuleV1(t *testing.T) {
|
||||||
|
parser := NewHyprlandRulesParser("")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
wantClass string
|
||||||
|
wantRule string
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic float rule",
|
||||||
|
line: "windowrule = float, ^(firefox)$",
|
||||||
|
wantClass: "^(firefox)$",
|
||||||
|
wantRule: "float",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tile rule",
|
||||||
|
line: "windowrule = tile, steam",
|
||||||
|
wantClass: "steam",
|
||||||
|
wantRule: "tile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no match returns empty class",
|
||||||
|
line: "windowrule = float",
|
||||||
|
wantClass: "",
|
||||||
|
wantRule: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parser.parseWindowRuleLine(tt.line)
|
||||||
|
if tt.wantNil {
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil, got %+v", result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.MatchClass != tt.wantClass {
|
||||||
|
t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass)
|
||||||
|
}
|
||||||
|
if result.Rule != tt.wantRule {
|
||||||
|
t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWindowRuleV2(t *testing.T) {
|
||||||
|
parser := NewHyprlandRulesParser("")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
wantClass string
|
||||||
|
wantTitle string
|
||||||
|
wantRule string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "float with class",
|
||||||
|
line: "windowrulev2 = float, class:^(firefox)$",
|
||||||
|
wantClass: "^(firefox)$",
|
||||||
|
wantRule: "float",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "opacity with value",
|
||||||
|
line: "windowrulev2 = opacity 0.8, class:^(code)$",
|
||||||
|
wantClass: "^(code)$",
|
||||||
|
wantRule: "opacity",
|
||||||
|
wantValue: "0.8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "size with value and title",
|
||||||
|
line: "windowrulev2 = size 800 600, class:^(steam)$, title:Settings",
|
||||||
|
wantClass: "^(steam)$",
|
||||||
|
wantTitle: "Settings",
|
||||||
|
wantRule: "size",
|
||||||
|
wantValue: "800 600",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parser.parseWindowRuleLine(tt.line)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.MatchClass != tt.wantClass {
|
||||||
|
t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass)
|
||||||
|
}
|
||||||
|
if result.MatchTitle != tt.wantTitle {
|
||||||
|
t.Errorf("MatchTitle = %q, want %q", result.MatchTitle, tt.wantTitle)
|
||||||
|
}
|
||||||
|
if result.Rule != tt.wantRule {
|
||||||
|
t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule)
|
||||||
|
}
|
||||||
|
if result.Value != tt.wantValue {
|
||||||
|
t.Errorf("Value = %q, want %q", result.Value, tt.wantValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertHyprlandRulesToWindowRules(t *testing.T) {
|
||||||
|
hyprRules := []HyprlandWindowRule{
|
||||||
|
{MatchClass: "^(firefox)$", Rule: "float"},
|
||||||
|
{MatchClass: "^(code)$", Rule: "opacity", Value: "0.9"},
|
||||||
|
{MatchClass: "^(steam)$", Rule: "maximize"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertHyprlandRulesToWindowRules(hyprRules)
|
||||||
|
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Errorf("expected 3 rules, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].MatchCriteria.AppID != "^(firefox)$" {
|
||||||
|
t.Errorf("rule 0 AppID = %q, want ^(firefox)$", result[0].MatchCriteria.AppID)
|
||||||
|
}
|
||||||
|
if result[0].Actions.OpenFloating == nil || !*result[0].Actions.OpenFloating {
|
||||||
|
t.Error("rule 0 should have OpenFloating = true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[1].Actions.Opacity == nil || *result[1].Actions.Opacity != 0.9 {
|
||||||
|
t.Errorf("rule 1 Opacity = %v, want 0.9", result[1].Actions.Opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[2].Actions.OpenMaximized == nil || !*result[2].Actions.OpenMaximized {
|
||||||
|
t.Error("rule 2 should have OpenMaximized = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandWritableProvider(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewHyprlandWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
if provider.Name() != "hyprland" {
|
||||||
|
t.Errorf("Name() = %q, want hyprland", provider.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
|
||||||
|
if provider.GetOverridePath() != expectedPath {
|
||||||
|
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewHyprlandWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
|
||||||
|
rule.Actions.OpenFloating = boolPtr(true)
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
t.Fatalf("SetRule failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := provider.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDMSRules failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rules[0].ID != "test_id" {
|
||||||
|
t.Errorf("ID = %q, want test_id", rules[0].ID)
|
||||||
|
}
|
||||||
|
if rules[0].MatchCriteria.AppID != "^(firefox)$" {
|
||||||
|
t.Errorf("AppID = %q, want ^(firefox)$", rules[0].MatchCriteria.AppID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandRemoveRule(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewHyprlandWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$")
|
||||||
|
rule1.Actions.OpenFloating = boolPtr(true)
|
||||||
|
rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$")
|
||||||
|
rule2.Actions.OpenFloating = boolPtr(true)
|
||||||
|
|
||||||
|
_ = provider.SetRule(rule1)
|
||||||
|
_ = provider.SetRule(rule2)
|
||||||
|
|
||||||
|
if err := provider.RemoveRule("rule1"); err != nil {
|
||||||
|
t.Fatalf("RemoveRule failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, _ := provider.LoadDMSRules()
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule after removal, got %d", len(rules))
|
||||||
|
}
|
||||||
|
if rules[0].ID != "rule2" {
|
||||||
|
t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandReorderRules(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewHyprlandWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$")
|
||||||
|
rule1.Actions.OpenFloating = boolPtr(true)
|
||||||
|
rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$")
|
||||||
|
rule2.Actions.OpenFloating = boolPtr(true)
|
||||||
|
rule3 := newTestWindowRule("rule3", "Rule 3", "^(app3)$")
|
||||||
|
rule3.Actions.OpenFloating = boolPtr(true)
|
||||||
|
|
||||||
|
_ = provider.SetRule(rule1)
|
||||||
|
_ = provider.SetRule(rule2)
|
||||||
|
_ = provider.SetRule(rule3)
|
||||||
|
|
||||||
|
if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil {
|
||||||
|
t.Fatalf("ReorderRules failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, _ := provider.LoadDMSRules()
|
||||||
|
if len(rules) != 3 {
|
||||||
|
t.Fatalf("expected 3 rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
expectedOrder := []string{"rule3", "rule1", "rule2"}
|
||||||
|
for i, expectedID := range expectedOrder {
|
||||||
|
if rules[i].ID != expectedID {
|
||||||
|
t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandParseConfigWithSource(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
mainConfig := `
|
||||||
|
windowrulev2 = float, class:^(mainapp)$
|
||||||
|
source = ./extra.conf
|
||||||
|
`
|
||||||
|
extraConfig := `
|
||||||
|
windowrulev2 = tile, class:^(extraapp)$
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte(mainConfig), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "extra.conf"), []byte(extraConfig), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := NewHyprlandRulesParser(tmpDir)
|
||||||
|
rules, err := parser.Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) != 2 {
|
||||||
|
t.Errorf("expected 2 rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoolToInt(t *testing.T) {
|
||||||
|
if boolToInt(true) != 1 {
|
||||||
|
t.Error("boolToInt(true) should be 1")
|
||||||
|
}
|
||||||
|
if boolToInt(false) != 0 {
|
||||||
|
t.Error("boolToInt(false) should be 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
873
core/internal/windowrules/providers/niri_parser.go
Normal file
873
core/internal/windowrules/providers/niri_parser.go
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sblinch/kdl-go"
|
||||||
|
"github.com/sblinch/kdl-go/document"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NiriWindowRule struct {
|
||||||
|
MatchAppID string
|
||||||
|
MatchTitle string
|
||||||
|
MatchIsFloating *bool
|
||||||
|
MatchIsActive *bool
|
||||||
|
MatchIsFocused *bool
|
||||||
|
MatchIsActiveInColumn *bool
|
||||||
|
MatchIsWindowCastTarget *bool
|
||||||
|
MatchIsUrgent *bool
|
||||||
|
MatchAtStartup *bool
|
||||||
|
Opacity *float64
|
||||||
|
OpenFloating *bool
|
||||||
|
OpenMaximized *bool
|
||||||
|
OpenMaximizedToEdges *bool
|
||||||
|
OpenFullscreen *bool
|
||||||
|
OpenFocused *bool
|
||||||
|
OpenOnOutput string
|
||||||
|
OpenOnWorkspace string
|
||||||
|
DefaultColumnWidth string
|
||||||
|
DefaultWindowHeight string
|
||||||
|
VariableRefreshRate *bool
|
||||||
|
BlockOutFrom string
|
||||||
|
DefaultColumnDisplay string
|
||||||
|
ScrollFactor *float64
|
||||||
|
CornerRadius *int
|
||||||
|
ClipToGeometry *bool
|
||||||
|
TiledState *bool
|
||||||
|
MinWidth *int
|
||||||
|
MaxWidth *int
|
||||||
|
MinHeight *int
|
||||||
|
MaxHeight *int
|
||||||
|
BorderColor string
|
||||||
|
FocusRingColor string
|
||||||
|
FocusRingOff *bool
|
||||||
|
BorderOff *bool
|
||||||
|
DrawBorderWithBg *bool
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NiriRulesParser struct {
|
||||||
|
configDir string
|
||||||
|
processedFiles map[string]bool
|
||||||
|
rules []NiriWindowRule
|
||||||
|
currentSource string
|
||||||
|
dmsRulesIncluded bool
|
||||||
|
dmsRulesExists bool
|
||||||
|
includeCount int
|
||||||
|
dmsIncludePos int
|
||||||
|
rulesAfterDMS int
|
||||||
|
dmsProcessed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNiriRulesParser(configDir string) *NiriRulesParser {
|
||||||
|
return &NiriRulesParser{
|
||||||
|
configDir: configDir,
|
||||||
|
processedFiles: make(map[string]bool),
|
||||||
|
rules: []NiriWindowRule{},
|
||||||
|
dmsIncludePos: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) Parse() ([]NiriWindowRule, error) {
|
||||||
|
dmsRulesPath := filepath.Join(p.configDir, "dms", "windowrules.kdl")
|
||||||
|
if _, err := os.Stat(dmsRulesPath); err == nil {
|
||||||
|
p.dmsRulesExists = true
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(p.configDir, "config.kdl")
|
||||||
|
if err := p.parseFile(configPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.dmsRulesExists && !p.dmsProcessed {
|
||||||
|
p.parseDMSRulesDirectly(dmsRulesPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
|
||||||
|
data, err := os.ReadFile(dmsRulesPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSource := p.currentSource
|
||||||
|
p.currentSource = dmsRulesPath
|
||||||
|
p.processNodes(doc.Nodes, filepath.Dir(dmsRulesPath))
|
||||||
|
p.currentSource = prevSource
|
||||||
|
p.dmsProcessed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseFile(filePath string) error {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.processedFiles[absPath] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.processedFiles[absPath] = true
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSource := p.currentSource
|
||||||
|
p.currentSource = absPath
|
||||||
|
baseDir := filepath.Dir(absPath)
|
||||||
|
p.processNodes(doc.Nodes, baseDir)
|
||||||
|
p.currentSource = prevSource
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) processNodes(nodes []*document.Node, baseDir string) {
|
||||||
|
for _, node := range nodes {
|
||||||
|
name := node.Name.String()
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "include":
|
||||||
|
p.handleInclude(node, baseDir)
|
||||||
|
case "window-rule":
|
||||||
|
p.parseWindowRuleNode(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) handleInclude(node *document.Node, baseDir string) {
|
||||||
|
if len(node.Arguments) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
includePath := strings.Trim(node.Arguments[0].String(), "\"")
|
||||||
|
isDMSInclude := includePath == "dms/windowrules.kdl" || strings.HasSuffix(includePath, "/dms/windowrules.kdl")
|
||||||
|
|
||||||
|
p.includeCount++
|
||||||
|
if isDMSInclude {
|
||||||
|
p.dmsRulesIncluded = true
|
||||||
|
p.dmsIncludePos = p.includeCount
|
||||||
|
p.dmsProcessed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(baseDir, includePath)
|
||||||
|
if filepath.IsAbs(includePath) {
|
||||||
|
fullPath = includePath
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = p.parseFile(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
|
||||||
|
if node.Children == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := NiriWindowRule{
|
||||||
|
Source: p.currentSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range node.Children {
|
||||||
|
childName := child.Name.String()
|
||||||
|
|
||||||
|
switch childName {
|
||||||
|
case "match":
|
||||||
|
p.parseMatchNode(child, &rule)
|
||||||
|
case "opacity":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
if f, ok := val.(float64); ok {
|
||||||
|
rule.Opacity = &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "open-floating":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.OpenFloating = &b
|
||||||
|
case "open-maximized":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.OpenMaximized = &b
|
||||||
|
case "open-maximized-to-edges":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.OpenMaximizedToEdges = &b
|
||||||
|
case "open-fullscreen":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.OpenFullscreen = &b
|
||||||
|
case "open-focused":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.OpenFocused = &b
|
||||||
|
case "open-on-output":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
rule.OpenOnOutput = child.Arguments[0].ValueString()
|
||||||
|
}
|
||||||
|
case "open-on-workspace":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
rule.OpenOnWorkspace = child.Arguments[0].ValueString()
|
||||||
|
}
|
||||||
|
case "default-column-width":
|
||||||
|
rule.DefaultColumnWidth = p.parseSizeNode(child)
|
||||||
|
case "default-window-height":
|
||||||
|
rule.DefaultWindowHeight = p.parseSizeNode(child)
|
||||||
|
case "variable-refresh-rate":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.VariableRefreshRate = &b
|
||||||
|
case "block-out-from":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
rule.BlockOutFrom = child.Arguments[0].ValueString()
|
||||||
|
}
|
||||||
|
case "default-column-display":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
rule.DefaultColumnDisplay = child.Arguments[0].ValueString()
|
||||||
|
}
|
||||||
|
case "scroll-factor":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
if f, ok := val.(float64); ok {
|
||||||
|
rule.ScrollFactor = &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "geometry-corner-radius":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
if i, ok := val.(int64); ok {
|
||||||
|
intVal := int(i)
|
||||||
|
rule.CornerRadius = &intVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "clip-to-geometry":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.ClipToGeometry = &b
|
||||||
|
case "tiled-state":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.TiledState = &b
|
||||||
|
case "min-width":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
if i, ok := val.(int64); ok {
|
||||||
|
intVal := int(i)
|
||||||
|
rule.MinWidth = &intVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "max-width":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
if i, ok := val.(int64); ok {
|
||||||
|
intVal := int(i)
|
||||||
|
rule.MaxWidth = &intVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "min-height":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
if i, ok := val.(int64); ok {
|
||||||
|
intVal := int(i)
|
||||||
|
rule.MinHeight = &intVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "max-height":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
if i, ok := val.(int64); ok {
|
||||||
|
intVal := int(i)
|
||||||
|
rule.MaxHeight = &intVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "border":
|
||||||
|
p.parseBorderNode(child, &rule)
|
||||||
|
case "focus-ring":
|
||||||
|
p.parseFocusRingNode(child, &rule)
|
||||||
|
case "draw-border-with-background":
|
||||||
|
b := p.parseBoolArg(child)
|
||||||
|
rule.DrawBorderWithBg = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.rules = append(p.rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
|
||||||
|
if node.Children == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, child := range node.Children {
|
||||||
|
name := child.Name.String()
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
val := child.Arguments[0].ResolvedValue()
|
||||||
|
switch name {
|
||||||
|
case "fixed":
|
||||||
|
if i, ok := val.(int64); ok {
|
||||||
|
return "fixed " + strconv.FormatInt(i, 10)
|
||||||
|
}
|
||||||
|
case "proportion":
|
||||||
|
if f, ok := val.(float64); ok {
|
||||||
|
return "proportion " + strconv.FormatFloat(f, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
|
||||||
|
if node.Properties == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := node.Properties.Get("app-id"); ok {
|
||||||
|
rule.MatchAppID = val.ValueString()
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("title"); ok {
|
||||||
|
rule.MatchTitle = val.ValueString()
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("is-floating"); ok {
|
||||||
|
b := val.ValueString() == "true"
|
||||||
|
rule.MatchIsFloating = &b
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("is-active"); ok {
|
||||||
|
b := val.ValueString() == "true"
|
||||||
|
rule.MatchIsActive = &b
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("is-focused"); ok {
|
||||||
|
b := val.ValueString() == "true"
|
||||||
|
rule.MatchIsFocused = &b
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("is-active-in-column"); ok {
|
||||||
|
b := val.ValueString() == "true"
|
||||||
|
rule.MatchIsActiveInColumn = &b
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
|
||||||
|
b := val.ValueString() == "true"
|
||||||
|
rule.MatchIsWindowCastTarget = &b
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("is-urgent"); ok {
|
||||||
|
b := val.ValueString() == "true"
|
||||||
|
rule.MatchIsUrgent = &b
|
||||||
|
}
|
||||||
|
if val, ok := node.Properties.Get("at-startup"); ok {
|
||||||
|
b := val.ValueString() == "true"
|
||||||
|
rule.MatchAtStartup = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
|
||||||
|
if node.Children == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range node.Children {
|
||||||
|
switch child.Name.String() {
|
||||||
|
case "off":
|
||||||
|
b := true
|
||||||
|
rule.BorderOff = &b
|
||||||
|
case "active-color":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
rule.BorderColor = child.Arguments[0].ValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
|
||||||
|
if node.Children == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range node.Children {
|
||||||
|
switch child.Name.String() {
|
||||||
|
case "off":
|
||||||
|
b := true
|
||||||
|
rule.FocusRingOff = &b
|
||||||
|
case "active-color":
|
||||||
|
if len(child.Arguments) > 0 {
|
||||||
|
rule.FocusRingColor = child.Arguments[0].ValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) parseBoolArg(node *document.Node) bool {
|
||||||
|
if len(node.Arguments) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return node.Arguments[0].ValueString() != "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) HasDMSRulesIncluded() bool {
|
||||||
|
return p.dmsRulesIncluded
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
|
||||||
|
status := &windowrules.DMSRulesStatus{
|
||||||
|
Exists: p.dmsRulesExists,
|
||||||
|
Included: p.dmsRulesIncluded,
|
||||||
|
IncludePosition: p.dmsIncludePos,
|
||||||
|
TotalIncludes: p.includeCount,
|
||||||
|
RulesAfterDMS: p.rulesAfterDMS,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !p.dmsRulesExists:
|
||||||
|
status.Effective = false
|
||||||
|
status.StatusMessage = "dms/windowrules.kdl does not exist"
|
||||||
|
case !p.dmsRulesIncluded:
|
||||||
|
status.Effective = false
|
||||||
|
status.StatusMessage = "dms/windowrules.kdl is not included in config.kdl"
|
||||||
|
case p.rulesAfterDMS > 0:
|
||||||
|
status.Effective = true
|
||||||
|
status.OverriddenBy = p.rulesAfterDMS
|
||||||
|
status.StatusMessage = "Some DMS rules may be overridden by config rules"
|
||||||
|
default:
|
||||||
|
status.Effective = true
|
||||||
|
status.StatusMessage = "DMS window rules are active"
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
type NiriRulesParseResult struct {
|
||||||
|
Rules []NiriWindowRule
|
||||||
|
DMSRulesIncluded bool
|
||||||
|
DMSStatus *windowrules.DMSRulesStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
|
||||||
|
parser := NewNiriRulesParser(configDir)
|
||||||
|
rules, err := parser.Parse()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &NiriRulesParseResult{
|
||||||
|
Rules: rules,
|
||||||
|
DMSRulesIncluded: parser.HasDMSRulesIncluded(),
|
||||||
|
DMSStatus: parser.buildDMSStatus(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
|
||||||
|
result := make([]windowrules.WindowRule, 0, len(niriRules))
|
||||||
|
for i, nr := range niriRules {
|
||||||
|
wr := windowrules.WindowRule{
|
||||||
|
ID: fmt.Sprintf("rule_%d", i),
|
||||||
|
Enabled: true,
|
||||||
|
Source: nr.Source,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: nr.MatchAppID,
|
||||||
|
Title: nr.MatchTitle,
|
||||||
|
IsFloating: nr.MatchIsFloating,
|
||||||
|
IsActive: nr.MatchIsActive,
|
||||||
|
IsFocused: nr.MatchIsFocused,
|
||||||
|
IsActiveInColumn: nr.MatchIsActiveInColumn,
|
||||||
|
IsWindowCastTarget: nr.MatchIsWindowCastTarget,
|
||||||
|
IsUrgent: nr.MatchIsUrgent,
|
||||||
|
AtStartup: nr.MatchAtStartup,
|
||||||
|
},
|
||||||
|
Actions: windowrules.Actions{
|
||||||
|
Opacity: nr.Opacity,
|
||||||
|
OpenFloating: nr.OpenFloating,
|
||||||
|
OpenMaximized: nr.OpenMaximized,
|
||||||
|
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
|
||||||
|
OpenFullscreen: nr.OpenFullscreen,
|
||||||
|
OpenFocused: nr.OpenFocused,
|
||||||
|
OpenOnOutput: nr.OpenOnOutput,
|
||||||
|
OpenOnWorkspace: nr.OpenOnWorkspace,
|
||||||
|
DefaultColumnWidth: nr.DefaultColumnWidth,
|
||||||
|
DefaultWindowHeight: nr.DefaultWindowHeight,
|
||||||
|
VariableRefreshRate: nr.VariableRefreshRate,
|
||||||
|
BlockOutFrom: nr.BlockOutFrom,
|
||||||
|
DefaultColumnDisplay: nr.DefaultColumnDisplay,
|
||||||
|
ScrollFactor: nr.ScrollFactor,
|
||||||
|
CornerRadius: nr.CornerRadius,
|
||||||
|
ClipToGeometry: nr.ClipToGeometry,
|
||||||
|
TiledState: nr.TiledState,
|
||||||
|
MinWidth: nr.MinWidth,
|
||||||
|
MaxWidth: nr.MaxWidth,
|
||||||
|
MinHeight: nr.MinHeight,
|
||||||
|
MaxHeight: nr.MaxHeight,
|
||||||
|
BorderColor: nr.BorderColor,
|
||||||
|
FocusRingColor: nr.FocusRingColor,
|
||||||
|
FocusRingOff: nr.FocusRingOff,
|
||||||
|
BorderOff: nr.BorderOff,
|
||||||
|
DrawBorderWithBg: nr.DrawBorderWithBg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = append(result, wr)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type NiriWritableProvider struct {
|
||||||
|
configDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNiriWritableProvider(configDir string) *NiriWritableProvider {
|
||||||
|
return &NiriWritableProvider{configDir: configDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) Name() string {
|
||||||
|
return "niri"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) GetOverridePath() string {
|
||||||
|
return filepath.Join(p.configDir, "dms", "windowrules.kdl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
|
||||||
|
result, err := ParseNiriWindowRules(p.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &windowrules.RuleSet{
|
||||||
|
Title: "Niri Window Rules",
|
||||||
|
Provider: "niri",
|
||||||
|
Rules: ConvertNiriRulesToWindowRules(result.Rules),
|
||||||
|
DMSRulesIncluded: result.DMSRulesIncluded,
|
||||||
|
DMSStatus: result.DMSStatus,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) SetRule(rule windowrules.WindowRule) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
rules = []windowrules.WindowRule{}
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, r := range rules {
|
||||||
|
if r.ID == rule.ID {
|
||||||
|
rules[i] = rule
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.writeDMSRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) RemoveRule(id string) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newRules := make([]windowrules.WindowRule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.ID != id {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.writeDMSRules(newRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) ReorderRules(ids []string) error {
|
||||||
|
rules, err := p.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleMap := make(map[string]windowrules.WindowRule)
|
||||||
|
for _, r := range rules {
|
||||||
|
ruleMap[r.ID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
newRules := make([]windowrules.WindowRule, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if r, ok := ruleMap[id]; ok {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
delete(ruleMap, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range ruleMap {
|
||||||
|
newRules = append(newRules, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.writeDMSRules(newRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
var niriMetaCommentRegex = regexp.MustCompile(`^//\s*@id=(\S*)\s*@name=(.*)$`)
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
|
||||||
|
rulesPath := p.GetOverridePath()
|
||||||
|
data, err := os.ReadFile(rulesPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []windowrules.WindowRule{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
|
||||||
|
type ruleMeta struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
var metas []ruleMeta
|
||||||
|
var currentID, currentName string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if matches := niriMetaCommentRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||||
|
currentID = matches[1]
|
||||||
|
currentName = strings.TrimSpace(matches[2])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "window-rule") {
|
||||||
|
metas = append(metas, ruleMeta{id: currentID, name: currentName})
|
||||||
|
currentID = ""
|
||||||
|
currentName = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := kdl.Parse(strings.NewReader(content))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := NewNiriRulesParser(p.configDir)
|
||||||
|
parser.currentSource = rulesPath
|
||||||
|
|
||||||
|
for _, node := range doc.Nodes {
|
||||||
|
if node.Name.String() == "window-rule" {
|
||||||
|
parser.parseWindowRuleNode(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []windowrules.WindowRule
|
||||||
|
for i, nr := range parser.rules {
|
||||||
|
id := ""
|
||||||
|
name := ""
|
||||||
|
if i < len(metas) {
|
||||||
|
id = metas[i].id
|
||||||
|
name = metas[i].name
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
id = fmt.Sprintf("dms_rule_%d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wr := windowrules.WindowRule{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Enabled: true,
|
||||||
|
Source: rulesPath,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: nr.MatchAppID,
|
||||||
|
Title: nr.MatchTitle,
|
||||||
|
IsFloating: nr.MatchIsFloating,
|
||||||
|
IsActive: nr.MatchIsActive,
|
||||||
|
IsFocused: nr.MatchIsFocused,
|
||||||
|
IsActiveInColumn: nr.MatchIsActiveInColumn,
|
||||||
|
IsWindowCastTarget: nr.MatchIsWindowCastTarget,
|
||||||
|
IsUrgent: nr.MatchIsUrgent,
|
||||||
|
AtStartup: nr.MatchAtStartup,
|
||||||
|
},
|
||||||
|
Actions: windowrules.Actions{
|
||||||
|
Opacity: nr.Opacity,
|
||||||
|
OpenFloating: nr.OpenFloating,
|
||||||
|
OpenMaximized: nr.OpenMaximized,
|
||||||
|
OpenMaximizedToEdges: nr.OpenMaximizedToEdges,
|
||||||
|
OpenFullscreen: nr.OpenFullscreen,
|
||||||
|
OpenFocused: nr.OpenFocused,
|
||||||
|
OpenOnOutput: nr.OpenOnOutput,
|
||||||
|
OpenOnWorkspace: nr.OpenOnWorkspace,
|
||||||
|
DefaultColumnWidth: nr.DefaultColumnWidth,
|
||||||
|
DefaultWindowHeight: nr.DefaultWindowHeight,
|
||||||
|
VariableRefreshRate: nr.VariableRefreshRate,
|
||||||
|
BlockOutFrom: nr.BlockOutFrom,
|
||||||
|
DefaultColumnDisplay: nr.DefaultColumnDisplay,
|
||||||
|
ScrollFactor: nr.ScrollFactor,
|
||||||
|
CornerRadius: nr.CornerRadius,
|
||||||
|
ClipToGeometry: nr.ClipToGeometry,
|
||||||
|
TiledState: nr.TiledState,
|
||||||
|
MinWidth: nr.MinWidth,
|
||||||
|
MaxWidth: nr.MaxWidth,
|
||||||
|
MinHeight: nr.MinHeight,
|
||||||
|
MaxHeight: nr.MaxHeight,
|
||||||
|
BorderColor: nr.BorderColor,
|
||||||
|
FocusRingColor: nr.FocusRingColor,
|
||||||
|
FocusRingOff: nr.FocusRingOff,
|
||||||
|
BorderOff: nr.BorderOff,
|
||||||
|
DrawBorderWithBg: nr.DrawBorderWithBg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rules = append(rules, wr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
|
||||||
|
rulesPath := p.GetOverridePath()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, "// DMS Window Rules - Managed by DankMaterialShell")
|
||||||
|
lines = append(lines, "// Do not edit manually - changes may be overwritten")
|
||||||
|
lines = append(lines, "")
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
lines = append(lines, p.formatRule(rule))
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
|
||||||
|
lines = append(lines, "window-rule {")
|
||||||
|
|
||||||
|
m := rule.MatchCriteria
|
||||||
|
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
|
||||||
|
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
|
||||||
|
m.IsUrgent != nil || m.AtStartup != nil {
|
||||||
|
var matchProps []string
|
||||||
|
if m.AppID != "" {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
|
||||||
|
}
|
||||||
|
if m.Title != "" {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
|
||||||
|
}
|
||||||
|
if m.IsFloating != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
|
||||||
|
}
|
||||||
|
if m.IsActive != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
|
||||||
|
}
|
||||||
|
if m.IsFocused != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
|
||||||
|
}
|
||||||
|
if m.IsActiveInColumn != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
|
||||||
|
}
|
||||||
|
if m.IsWindowCastTarget != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
|
||||||
|
}
|
||||||
|
if m.IsUrgent != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
|
||||||
|
}
|
||||||
|
if m.AtStartup != nil {
|
||||||
|
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
|
||||||
|
}
|
||||||
|
lines = append(lines, " match "+strings.Join(matchProps, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
a := rule.Actions
|
||||||
|
if a.Opacity != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity))
|
||||||
|
}
|
||||||
|
if a.OpenFloating != nil && *a.OpenFloating {
|
||||||
|
lines = append(lines, " open-floating true")
|
||||||
|
}
|
||||||
|
if a.OpenMaximized != nil && *a.OpenMaximized {
|
||||||
|
lines = append(lines, " open-maximized true")
|
||||||
|
}
|
||||||
|
if a.OpenMaximizedToEdges != nil && *a.OpenMaximizedToEdges {
|
||||||
|
lines = append(lines, " open-maximized-to-edges true")
|
||||||
|
}
|
||||||
|
if a.OpenFullscreen != nil && *a.OpenFullscreen {
|
||||||
|
lines = append(lines, " open-fullscreen true")
|
||||||
|
}
|
||||||
|
if a.OpenFocused != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" open-focused %t", *a.OpenFocused))
|
||||||
|
}
|
||||||
|
if a.OpenOnOutput != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" open-on-output %q", a.OpenOnOutput))
|
||||||
|
}
|
||||||
|
if a.OpenOnWorkspace != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" open-on-workspace %q", a.OpenOnWorkspace))
|
||||||
|
}
|
||||||
|
if a.DefaultColumnWidth != "" {
|
||||||
|
lines = append(lines, formatSizeProperty("default-column-width", a.DefaultColumnWidth))
|
||||||
|
}
|
||||||
|
if a.DefaultWindowHeight != "" {
|
||||||
|
lines = append(lines, formatSizeProperty("default-window-height", a.DefaultWindowHeight))
|
||||||
|
}
|
||||||
|
if a.VariableRefreshRate != nil && *a.VariableRefreshRate {
|
||||||
|
lines = append(lines, " variable-refresh-rate true")
|
||||||
|
}
|
||||||
|
if a.BlockOutFrom != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" block-out-from %q", a.BlockOutFrom))
|
||||||
|
}
|
||||||
|
if a.DefaultColumnDisplay != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" default-column-display %q", a.DefaultColumnDisplay))
|
||||||
|
}
|
||||||
|
if a.ScrollFactor != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" scroll-factor %.2f", *a.ScrollFactor))
|
||||||
|
}
|
||||||
|
if a.CornerRadius != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" geometry-corner-radius %d", *a.CornerRadius))
|
||||||
|
}
|
||||||
|
if a.ClipToGeometry != nil && *a.ClipToGeometry {
|
||||||
|
lines = append(lines, " clip-to-geometry true")
|
||||||
|
}
|
||||||
|
if a.TiledState != nil && *a.TiledState {
|
||||||
|
lines = append(lines, " tiled-state true")
|
||||||
|
}
|
||||||
|
if a.MinWidth != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" min-width %d", *a.MinWidth))
|
||||||
|
}
|
||||||
|
if a.MaxWidth != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" max-width %d", *a.MaxWidth))
|
||||||
|
}
|
||||||
|
if a.MinHeight != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" min-height %d", *a.MinHeight))
|
||||||
|
}
|
||||||
|
if a.MaxHeight != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" max-height %d", *a.MaxHeight))
|
||||||
|
}
|
||||||
|
if a.BorderOff != nil && *a.BorderOff {
|
||||||
|
lines = append(lines, " border { off; }")
|
||||||
|
} else if a.BorderColor != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" border { active-color %q; }", a.BorderColor))
|
||||||
|
}
|
||||||
|
if a.FocusRingOff != nil && *a.FocusRingOff {
|
||||||
|
lines = append(lines, " focus-ring { off; }")
|
||||||
|
} else if a.FocusRingColor != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" focus-ring { active-color %q; }", a.FocusRingColor))
|
||||||
|
}
|
||||||
|
if a.DrawBorderWithBg != nil {
|
||||||
|
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, "}")
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSizeProperty(name, value string) string {
|
||||||
|
parts := strings.SplitN(value, " ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Sprintf(" %s { }", name)
|
||||||
|
}
|
||||||
|
sizeType := parts[0]
|
||||||
|
sizeValue := parts[1]
|
||||||
|
return fmt.Sprintf(" %s { %s %s; }", name, sizeType, sizeValue)
|
||||||
|
}
|
||||||
335
core/internal/windowrules/providers/niri_parser_test.go
Normal file
335
core/internal/windowrules/providers/niri_parser_test.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNiriParseBasicWindowRule(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
config := `
|
||||||
|
window-rule {
|
||||||
|
match app-id="^firefox$"
|
||||||
|
opacity 0.9
|
||||||
|
open-floating true
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := NewNiriRulesParser(tmpDir)
|
||||||
|
rules, err := parser.Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := rules[0]
|
||||||
|
if rule.MatchAppID != "^firefox$" {
|
||||||
|
t.Errorf("MatchAppID = %q, want ^firefox$", rule.MatchAppID)
|
||||||
|
}
|
||||||
|
if rule.Opacity == nil || *rule.Opacity != 0.9 {
|
||||||
|
t.Errorf("Opacity = %v, want 0.9", rule.Opacity)
|
||||||
|
}
|
||||||
|
if rule.OpenFloating == nil || !*rule.OpenFloating {
|
||||||
|
t.Error("OpenFloating should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriParseMultipleRules(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
config := `
|
||||||
|
window-rule {
|
||||||
|
match app-id="app1"
|
||||||
|
open-maximized true
|
||||||
|
}
|
||||||
|
|
||||||
|
window-rule {
|
||||||
|
match app-id="app2"
|
||||||
|
open-fullscreen true
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := NewNiriRulesParser(tmpDir)
|
||||||
|
rules, err := parser.Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) != 2 {
|
||||||
|
t.Fatalf("expected 2 rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rules[0].MatchAppID != "app1" {
|
||||||
|
t.Errorf("rule 0 MatchAppID = %q, want app1", rules[0].MatchAppID)
|
||||||
|
}
|
||||||
|
if rules[1].MatchAppID != "app2" {
|
||||||
|
t.Errorf("rule 1 MatchAppID = %q, want app2", rules[1].MatchAppID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertNiriRulesToWindowRules(t *testing.T) {
|
||||||
|
niriRules := []NiriWindowRule{
|
||||||
|
{MatchAppID: "^firefox$", Opacity: floatPtr(0.8)},
|
||||||
|
{MatchAppID: "^code$", OpenFloating: boolPtr(true)},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConvertNiriRulesToWindowRules(niriRules)
|
||||||
|
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Errorf("expected 2 rules, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].MatchCriteria.AppID != "^firefox$" {
|
||||||
|
t.Errorf("rule 0 AppID = %q, want ^firefox$", result[0].MatchCriteria.AppID)
|
||||||
|
}
|
||||||
|
if result[0].Actions.Opacity == nil || *result[0].Actions.Opacity != 0.8 {
|
||||||
|
t.Errorf("rule 0 Opacity = %v, want 0.8", result[0].Actions.Opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[1].Actions.OpenFloating == nil || !*result[1].Actions.OpenFloating {
|
||||||
|
t.Error("rule 1 should have OpenFloating = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriWritableProvider(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewNiriWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
if provider.Name() != "niri" {
|
||||||
|
t.Errorf("Name() = %q, want niri", provider.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.kdl")
|
||||||
|
if provider.GetOverridePath() != expectedPath {
|
||||||
|
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriSetAndLoadDMSRules(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewNiriWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
rule := newTestWindowRule("test_id", "Test Rule", "^firefox$")
|
||||||
|
rule.Actions.OpenFloating = boolPtr(true)
|
||||||
|
rule.Actions.Opacity = floatPtr(0.85)
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
t.Fatalf("SetRule failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := provider.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDMSRules failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rules[0].ID != "test_id" {
|
||||||
|
t.Errorf("ID = %q, want test_id", rules[0].ID)
|
||||||
|
}
|
||||||
|
if rules[0].MatchCriteria.AppID != "^firefox$" {
|
||||||
|
t.Errorf("AppID = %q, want ^firefox$", rules[0].MatchCriteria.AppID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriRemoveRule(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewNiriWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
rule1 := newTestWindowRule("rule1", "Rule 1", "app1")
|
||||||
|
rule1.Actions.OpenFloating = boolPtr(true)
|
||||||
|
rule2 := newTestWindowRule("rule2", "Rule 2", "app2")
|
||||||
|
rule2.Actions.OpenFloating = boolPtr(true)
|
||||||
|
|
||||||
|
_ = provider.SetRule(rule1)
|
||||||
|
_ = provider.SetRule(rule2)
|
||||||
|
|
||||||
|
if err := provider.RemoveRule("rule1"); err != nil {
|
||||||
|
t.Fatalf("RemoveRule failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, _ := provider.LoadDMSRules()
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule after removal, got %d", len(rules))
|
||||||
|
}
|
||||||
|
if rules[0].ID != "rule2" {
|
||||||
|
t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriReorderRules(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewNiriWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
rule1 := newTestWindowRule("rule1", "Rule 1", "app1")
|
||||||
|
rule1.Actions.OpenFloating = boolPtr(true)
|
||||||
|
rule2 := newTestWindowRule("rule2", "Rule 2", "app2")
|
||||||
|
rule2.Actions.OpenFloating = boolPtr(true)
|
||||||
|
rule3 := newTestWindowRule("rule3", "Rule 3", "app3")
|
||||||
|
rule3.Actions.OpenFloating = boolPtr(true)
|
||||||
|
|
||||||
|
_ = provider.SetRule(rule1)
|
||||||
|
_ = provider.SetRule(rule2)
|
||||||
|
_ = provider.SetRule(rule3)
|
||||||
|
|
||||||
|
if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil {
|
||||||
|
t.Fatalf("ReorderRules failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, _ := provider.LoadDMSRules()
|
||||||
|
if len(rules) != 3 {
|
||||||
|
t.Fatalf("expected 3 rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
expectedOrder := []string{"rule3", "rule1", "rule2"}
|
||||||
|
for i, expectedID := range expectedOrder {
|
||||||
|
if rules[i].ID != expectedID {
|
||||||
|
t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriParseConfigWithInclude(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
mainConfig := `
|
||||||
|
window-rule {
|
||||||
|
match app-id="mainapp"
|
||||||
|
opacity 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
include "extra.kdl"
|
||||||
|
`
|
||||||
|
extraConfig := `
|
||||||
|
window-rule {
|
||||||
|
match app-id="extraapp"
|
||||||
|
open-maximized true
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(mainConfig), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "extra.kdl"), []byte(extraConfig), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := NewNiriRulesParser(tmpDir)
|
||||||
|
rules, err := parser.Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) != 2 {
|
||||||
|
t.Errorf("expected 2 rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriParseSizeNode(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
config := `
|
||||||
|
window-rule {
|
||||||
|
match app-id="testapp"
|
||||||
|
default-column-width { fixed 800; }
|
||||||
|
default-window-height { proportion 0.5; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := NewNiriRulesParser(tmpDir)
|
||||||
|
rules, err := parser.Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rules[0].DefaultColumnWidth != "fixed 800" {
|
||||||
|
t.Errorf("DefaultColumnWidth = %q, want 'fixed 800'", rules[0].DefaultColumnWidth)
|
||||||
|
}
|
||||||
|
if rules[0].DefaultWindowHeight != "proportion 0.5" {
|
||||||
|
t.Errorf("DefaultWindowHeight = %q, want 'proportion 0.5'", rules[0].DefaultWindowHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatSizeProperty(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
propName string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fixed size",
|
||||||
|
propName: "default-column-width",
|
||||||
|
value: "fixed 800",
|
||||||
|
want: " default-column-width { fixed 800; }",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "proportion",
|
||||||
|
propName: "default-window-height",
|
||||||
|
value: "proportion 0.5",
|
||||||
|
want: " default-window-height { proportion 0.5; }",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
propName: "default-column-width",
|
||||||
|
value: "invalid",
|
||||||
|
want: " default-column-width { }",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := formatSizeProperty(tt.propName, tt.value)
|
||||||
|
if result != tt.want {
|
||||||
|
t.Errorf("formatSizeProperty(%q, %q) = %q, want %q",
|
||||||
|
tt.propName, tt.value, result, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriDMSRulesStatus(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
config := `
|
||||||
|
window-rule {
|
||||||
|
match app-id="testapp"
|
||||||
|
opacity 0.9
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseNiriWindowRules(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNiriWindowRules failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.DMSStatus == nil {
|
||||||
|
t.Fatal("DMSStatus should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.DMSStatus.Exists {
|
||||||
|
t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
22
core/internal/windowrules/providers/providers_test.go
Normal file
22
core/internal/windowrules/providers/providers_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
|
|
||||||
|
func newTestWindowRule(id, name, appID string) windowrules.WindowRule {
|
||||||
|
return windowrules.WindowRule{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Enabled: true,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: appID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatPtr(f float64) *float64 {
|
||||||
|
return &f
|
||||||
|
}
|
||||||
103
core/internal/windowrules/types.go
Normal file
103
core/internal/windowrules/types.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package windowrules
|
||||||
|
|
||||||
|
type MatchCriteria struct {
|
||||||
|
AppID string `json:"appId,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
IsFloating *bool `json:"isFloating,omitempty"`
|
||||||
|
IsActive *bool `json:"isActive,omitempty"`
|
||||||
|
IsFocused *bool `json:"isFocused,omitempty"`
|
||||||
|
IsActiveInColumn *bool `json:"isActiveInColumn,omitempty"`
|
||||||
|
IsWindowCastTarget *bool `json:"isWindowCastTarget,omitempty"`
|
||||||
|
IsUrgent *bool `json:"isUrgent,omitempty"`
|
||||||
|
AtStartup *bool `json:"atStartup,omitempty"`
|
||||||
|
XWayland *bool `json:"xwayland,omitempty"`
|
||||||
|
Fullscreen *bool `json:"fullscreen,omitempty"`
|
||||||
|
Pinned *bool `json:"pinned,omitempty"`
|
||||||
|
Initialised *bool `json:"initialised,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Actions struct {
|
||||||
|
Opacity *float64 `json:"opacity,omitempty"`
|
||||||
|
OpenFloating *bool `json:"openFloating,omitempty"`
|
||||||
|
OpenMaximized *bool `json:"openMaximized,omitempty"`
|
||||||
|
OpenMaximizedToEdges *bool `json:"openMaximizedToEdges,omitempty"`
|
||||||
|
OpenFullscreen *bool `json:"openFullscreen,omitempty"`
|
||||||
|
OpenFocused *bool `json:"openFocused,omitempty"`
|
||||||
|
OpenOnOutput string `json:"openOnOutput,omitempty"`
|
||||||
|
OpenOnWorkspace string `json:"openOnWorkspace,omitempty"`
|
||||||
|
DefaultColumnWidth string `json:"defaultColumnWidth,omitempty"`
|
||||||
|
DefaultWindowHeight string `json:"defaultWindowHeight,omitempty"`
|
||||||
|
VariableRefreshRate *bool `json:"variableRefreshRate,omitempty"`
|
||||||
|
BlockOutFrom string `json:"blockOutFrom,omitempty"`
|
||||||
|
DefaultColumnDisplay string `json:"defaultColumnDisplay,omitempty"`
|
||||||
|
ScrollFactor *float64 `json:"scrollFactor,omitempty"`
|
||||||
|
CornerRadius *int `json:"cornerRadius,omitempty"`
|
||||||
|
ClipToGeometry *bool `json:"clipToGeometry,omitempty"`
|
||||||
|
TiledState *bool `json:"tiledState,omitempty"`
|
||||||
|
MinWidth *int `json:"minWidth,omitempty"`
|
||||||
|
MaxWidth *int `json:"maxWidth,omitempty"`
|
||||||
|
MinHeight *int `json:"minHeight,omitempty"`
|
||||||
|
MaxHeight *int `json:"maxHeight,omitempty"`
|
||||||
|
BorderColor string `json:"borderColor,omitempty"`
|
||||||
|
FocusRingColor string `json:"focusRingColor,omitempty"`
|
||||||
|
FocusRingOff *bool `json:"focusRingOff,omitempty"`
|
||||||
|
BorderOff *bool `json:"borderOff,omitempty"`
|
||||||
|
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
|
||||||
|
Size string `json:"size,omitempty"`
|
||||||
|
Move string `json:"move,omitempty"`
|
||||||
|
Monitor string `json:"monitor,omitempty"`
|
||||||
|
Workspace string `json:"workspace,omitempty"`
|
||||||
|
Tile *bool `json:"tile,omitempty"`
|
||||||
|
NoFocus *bool `json:"nofocus,omitempty"`
|
||||||
|
NoBorder *bool `json:"noborder,omitempty"`
|
||||||
|
NoShadow *bool `json:"noshadow,omitempty"`
|
||||||
|
NoDim *bool `json:"nodim,omitempty"`
|
||||||
|
NoBlur *bool `json:"noblur,omitempty"`
|
||||||
|
NoAnim *bool `json:"noanim,omitempty"`
|
||||||
|
NoRounding *bool `json:"norounding,omitempty"`
|
||||||
|
Pin *bool `json:"pin,omitempty"`
|
||||||
|
Opaque *bool `json:"opaque,omitempty"`
|
||||||
|
ForcergbX *bool `json:"forcergbx,omitempty"`
|
||||||
|
Idleinhibit string `json:"idleinhibit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MatchCriteria MatchCriteria `json:"matchCriteria"`
|
||||||
|
Actions Actions `json:"actions"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DMSRulesStatus struct {
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
Included bool `json:"included"`
|
||||||
|
IncludePosition int `json:"includePosition"`
|
||||||
|
TotalIncludes int `json:"totalIncludes"`
|
||||||
|
RulesAfterDMS int `json:"rulesAfterDms"`
|
||||||
|
Effective bool `json:"effective"`
|
||||||
|
OverriddenBy int `json:"overriddenBy"`
|
||||||
|
StatusMessage string `json:"statusMessage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuleSet struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Rules []WindowRule `json:"rules"`
|
||||||
|
DMSRulesIncluded bool `json:"dmsRulesIncluded"`
|
||||||
|
DMSStatus *DMSRulesStatus `json:"dmsStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
Name() string
|
||||||
|
GetRuleSet() (*RuleSet, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WritableProvider interface {
|
||||||
|
Provider
|
||||||
|
SetRule(rule WindowRule) error
|
||||||
|
RemoveRule(id string) error
|
||||||
|
ReorderRules(ids []string) error
|
||||||
|
GetOverridePath() string
|
||||||
|
}
|
||||||
@@ -661,6 +661,18 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LazyLoader {
|
||||||
|
id: windowRuleModalLoader
|
||||||
|
|
||||||
|
active: false
|
||||||
|
|
||||||
|
Component.onCompleted: PopoutService.windowRuleModalLoader = windowRuleModalLoader
|
||||||
|
|
||||||
|
WindowRuleModal {
|
||||||
|
id: windowRuleModal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: processListModalLoader
|
id: processListModalLoader
|
||||||
|
|
||||||
@@ -787,6 +799,7 @@ Item {
|
|||||||
dankBarRepeater: dankBarRepeater
|
dankBarRepeater: dankBarRepeater
|
||||||
hyprlandOverviewLoader: hyprlandOverviewLoader
|
hyprlandOverviewLoader: hyprlandOverviewLoader
|
||||||
workspaceRenameModalLoader: workspaceRenameModalLoader
|
workspaceRenameModalLoader: workspaceRenameModalLoader
|
||||||
|
windowRuleModalLoader: windowRuleModalLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Hyprland
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Wayland
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ Item {
|
|||||||
required property var dankBarRepeater
|
required property var dankBarRepeater
|
||||||
required property var hyprlandOverviewLoader
|
required property var hyprlandOverviewLoader
|
||||||
required property var workspaceRenameModalLoader
|
required property var workspaceRenameModalLoader
|
||||||
|
required property var windowRuleModalLoader
|
||||||
|
|
||||||
function getFirstBar() {
|
function getFirstBar() {
|
||||||
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
|
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
|
||||||
@@ -1402,4 +1404,52 @@ Item {
|
|||||||
|
|
||||||
target: "workspace-rename"
|
target: "workspace-rename"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
function getFocusedWindow() {
|
||||||
|
const active = ToplevelManager.activeToplevel;
|
||||||
|
if (!active)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
appId: active.appId || "",
|
||||||
|
title: active.title || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(): string {
|
||||||
|
if (!CompositorService.isNiri)
|
||||||
|
return "WINDOW_RULES_NIRI_ONLY";
|
||||||
|
root.windowRuleModalLoader.active = true;
|
||||||
|
if (root.windowRuleModalLoader.item) {
|
||||||
|
root.windowRuleModalLoader.item.show(getFocusedWindow());
|
||||||
|
return "WINDOW_RULE_MODAL_OPENED";
|
||||||
|
}
|
||||||
|
return "WINDOW_RULE_MODAL_NOT_FOUND";
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): string {
|
||||||
|
if (root.windowRuleModalLoader.item) {
|
||||||
|
root.windowRuleModalLoader.item.hide();
|
||||||
|
return "WINDOW_RULE_MODAL_CLOSED";
|
||||||
|
}
|
||||||
|
return "WINDOW_RULE_MODAL_NOT_FOUND";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(): string {
|
||||||
|
if (!CompositorService.isNiri)
|
||||||
|
return "WINDOW_RULES_NIRI_ONLY";
|
||||||
|
root.windowRuleModalLoader.active = true;
|
||||||
|
if (root.windowRuleModalLoader.item) {
|
||||||
|
if (root.windowRuleModalLoader.item.visible) {
|
||||||
|
root.windowRuleModalLoader.item.hide();
|
||||||
|
return "WINDOW_RULE_MODAL_CLOSED";
|
||||||
|
}
|
||||||
|
root.windowRuleModalLoader.item.show(getFocusedWindow());
|
||||||
|
return "WINDOW_RULE_MODAL_OPENED";
|
||||||
|
}
|
||||||
|
return "WINDOW_RULE_MODAL_NOT_FOUND";
|
||||||
|
}
|
||||||
|
|
||||||
|
target: "window-rules"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,11 +64,18 @@ Popup {
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function executePluginAction(actionFunc) {
|
function executePluginAction(actionOrObj) {
|
||||||
if (typeof actionFunc === "function") {
|
var actionFunc = typeof actionOrObj === "function" ? actionOrObj : actionOrObj?.action;
|
||||||
|
var closeLauncher = typeof actionOrObj === "object" && actionOrObj?.closeLauncher;
|
||||||
|
|
||||||
|
if (typeof actionFunc === "function")
|
||||||
actionFunc();
|
actionFunc();
|
||||||
|
|
||||||
|
if (closeLauncher) {
|
||||||
|
controller?.itemExecuted();
|
||||||
|
} else {
|
||||||
|
controller?.performSearch();
|
||||||
}
|
}
|
||||||
controller?.performSearch();
|
|
||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +90,7 @@ Popup {
|
|||||||
type: "item",
|
type: "item",
|
||||||
icon: act.icon || "play_arrow",
|
icon: act.icon || "play_arrow",
|
||||||
text: act.text || act.name || "",
|
text: act.text || act.name || "",
|
||||||
pluginAction: act.action
|
pluginAction: act
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
@@ -441,5 +441,22 @@ FocusScope {
|
|||||||
Qt.callLater(() => item.forceActiveFocus());
|
Qt.callLater(() => item.forceActiveFocus());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: windowRulesLoader
|
||||||
|
anchors.fill: parent
|
||||||
|
active: root.currentIndex === 28
|
||||||
|
visible: active
|
||||||
|
focus: active
|
||||||
|
|
||||||
|
sourceComponent: WindowRulesTab {
|
||||||
|
parentModal: root.parentModal
|
||||||
|
}
|
||||||
|
|
||||||
|
onActiveChanged: {
|
||||||
|
if (active && item)
|
||||||
|
Qt.callLater(() => item.forceActiveFocus());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,13 @@ Rectangle {
|
|||||||
"icon": "content_paste",
|
"icon": "content_paste",
|
||||||
"tabIndex": 23,
|
"tabIndex": 23,
|
||||||
"clipboardOnly": true
|
"clipboardOnly": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "window_rules",
|
||||||
|
"text": I18n.tr("Window Rules"),
|
||||||
|
"icon": "select_window",
|
||||||
|
"tabIndex": 28,
|
||||||
|
"niriOnly": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -304,6 +311,8 @@ Rectangle {
|
|||||||
return false;
|
return false;
|
||||||
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
|
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
|
||||||
return false;
|
return false;
|
||||||
|
if (item.niriOnly && !CompositorService.isNiri)
|
||||||
|
return false;
|
||||||
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
|
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
|
||||||
return false;
|
return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
1143
quickshell/Modals/WindowRuleModal.qml
Normal file
1143
quickshell/Modals/WindowRuleModal.qml
Normal file
File diff suppressed because it is too large
Load Diff
690
quickshell/Modules/Settings/WindowRulesTab.qml
Normal file
690
quickshell/Modules/Settings/WindowRulesTab.qml
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtCore
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
LayoutMirroring.enabled: I18n.isRtl
|
||||||
|
LayoutMirroring.childrenInherit: true
|
||||||
|
|
||||||
|
property var parentModal: null
|
||||||
|
property var windowRulesIncludeStatus: ({
|
||||||
|
"exists": false,
|
||||||
|
"included": false
|
||||||
|
})
|
||||||
|
property bool checkingInclude: false
|
||||||
|
property bool fixingInclude: false
|
||||||
|
property var windowRules: []
|
||||||
|
property var activeWindows: getActiveWindows()
|
||||||
|
|
||||||
|
signal rulesChanged
|
||||||
|
|
||||||
|
function getActiveWindows() {
|
||||||
|
const toplevels = ToplevelManager.toplevels?.values || [];
|
||||||
|
return toplevels.map(t => ({
|
||||||
|
appId: t.appId || "",
|
||||||
|
title: t.title || ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ToplevelManager.toplevels
|
||||||
|
function onValuesChanged() {
|
||||||
|
root.activeWindows = root.getActiveWindows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowRulesConfigPaths() {
|
||||||
|
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
|
||||||
|
switch (CompositorService.compositor) {
|
||||||
|
case "niri":
|
||||||
|
return {
|
||||||
|
"configFile": configDir + "/niri/config.kdl",
|
||||||
|
"rulesFile": configDir + "/niri/dms/windowrules.kdl",
|
||||||
|
"grepPattern": 'include.*"dms/windowrules.kdl"',
|
||||||
|
"includeLine": 'include "dms/windowrules.kdl"'
|
||||||
|
};
|
||||||
|
case "hyprland":
|
||||||
|
return {
|
||||||
|
"configFile": configDir + "/hypr/hyprland.conf",
|
||||||
|
"rulesFile": configDir + "/hypr/dms/windowrules.conf",
|
||||||
|
"grepPattern": 'source.*dms/windowrules.conf',
|
||||||
|
"includeLine": "source = ./dms/windowrules.conf"
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWindowRules() {
|
||||||
|
const compositor = CompositorService.compositor;
|
||||||
|
if (compositor !== "niri" && compositor !== "hyprland") {
|
||||||
|
windowRules = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Proc.runCommand("load-windowrules", ["dms", "config", "windowrules", "list", compositor], (output, exitCode) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
windowRules = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(output.trim());
|
||||||
|
const allRules = result.rules || [];
|
||||||
|
windowRules = allRules.filter(r => (r.source || "").includes("dms/windowrules"));
|
||||||
|
if (result.dmsStatus) {
|
||||||
|
windowRulesIncludeStatus = {
|
||||||
|
"exists": result.dmsStatus.exists,
|
||||||
|
"included": result.dmsStatus.included
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
windowRules = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRule(ruleId) {
|
||||||
|
const compositor = CompositorService.compositor;
|
||||||
|
if (compositor !== "niri" && compositor !== "hyprland")
|
||||||
|
return;
|
||||||
|
|
||||||
|
Proc.runCommand("remove-windowrule", ["dms", "config", "windowrules", "remove", compositor, ruleId], (output, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
loadWindowRules();
|
||||||
|
rulesChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderRules(fromIndex, toIndex) {
|
||||||
|
if (fromIndex === toIndex)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const compositor = CompositorService.compositor;
|
||||||
|
if (compositor !== "niri" && compositor !== "hyprland")
|
||||||
|
return;
|
||||||
|
|
||||||
|
let ids = windowRules.map(r => r.id);
|
||||||
|
const [moved] = ids.splice(fromIndex, 1);
|
||||||
|
ids.splice(toIndex, 0, moved);
|
||||||
|
|
||||||
|
Proc.runCommand("reorder-windowrules", ["dms", "config", "windowrules", "reorder", compositor, JSON.stringify(ids)], (output, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
loadWindowRules();
|
||||||
|
rulesChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkWindowRulesIncludeStatus() {
|
||||||
|
const compositor = CompositorService.compositor;
|
||||||
|
if (compositor !== "niri" && compositor !== "hyprland") {
|
||||||
|
windowRulesIncludeStatus = {
|
||||||
|
"exists": false,
|
||||||
|
"included": false
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = (compositor === "niri") ? "windowrules.kdl" : "windowrules.conf";
|
||||||
|
checkingInclude = true;
|
||||||
|
Proc.runCommand("check-windowrules-include", ["dms", "config", "resolve-include", compositor, filename], (output, exitCode) => {
|
||||||
|
checkingInclude = false;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
windowRulesIncludeStatus = {
|
||||||
|
"exists": false,
|
||||||
|
"included": false
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
windowRulesIncludeStatus = JSON.parse(output.trim());
|
||||||
|
} catch (e) {
|
||||||
|
windowRulesIncludeStatus = {
|
||||||
|
"exists": false,
|
||||||
|
"included": false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixWindowRulesInclude() {
|
||||||
|
const paths = getWindowRulesConfigPaths();
|
||||||
|
if (!paths)
|
||||||
|
return;
|
||||||
|
fixingInclude = true;
|
||||||
|
const rulesDir = paths.rulesFile.substring(0, paths.rulesFile.lastIndexOf("/"));
|
||||||
|
const unixTime = Math.floor(Date.now() / 1000);
|
||||||
|
const backupFile = paths.configFile + ".backup" + unixTime;
|
||||||
|
Proc.runCommand("fix-windowrules-include", ["sh", "-c", `cp "${paths.configFile}" "${backupFile}" 2>/dev/null; ` + `mkdir -p "${rulesDir}" && ` + `touch "${paths.rulesFile}" && ` + `if ! grep -v '^[[:space:]]*\\(//\\|#\\)' "${paths.configFile}" 2>/dev/null | grep -q '${paths.grepPattern}'; then ` + `echo '' >> "${paths.configFile}" && ` + `echo '${paths.includeLine}' >> "${paths.configFile}"; fi`], (output, exitCode) => {
|
||||||
|
fixingInclude = false;
|
||||||
|
if (exitCode !== 0)
|
||||||
|
return;
|
||||||
|
checkWindowRulesIncludeStatus();
|
||||||
|
loadWindowRules();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRuleModal(window) {
|
||||||
|
if (!PopoutService.windowRuleModalLoader)
|
||||||
|
return;
|
||||||
|
PopoutService.windowRuleModalLoader.active = true;
|
||||||
|
if (PopoutService.windowRuleModalLoader.item) {
|
||||||
|
PopoutService.windowRuleModalLoader.item.onRuleSubmitted.connect(loadWindowRules);
|
||||||
|
PopoutService.windowRuleModalLoader.item.show(window || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRule(rule) {
|
||||||
|
if (!PopoutService.windowRuleModalLoader)
|
||||||
|
return;
|
||||||
|
PopoutService.windowRuleModalLoader.active = true;
|
||||||
|
if (PopoutService.windowRuleModalLoader.item) {
|
||||||
|
PopoutService.windowRuleModalLoader.item.onRuleSubmitted.connect(loadWindowRules);
|
||||||
|
PopoutService.windowRuleModalLoader.item.showEdit(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (CompositorService.isNiri || CompositorService.isHyprland) {
|
||||||
|
checkWindowRulesIncludeStatus();
|
||||||
|
loadWindowRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: headerSection
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingL
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "select_window"
|
||||||
|
size: Theme.iconSize
|
||||||
|
color: Theme.primary
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Window Rules")
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Define rules for window behavior. Saves to %1").arg(CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.conf")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
Layout.preferredWidth: 40
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
circular: false
|
||||||
|
iconName: "add"
|
||||||
|
iconSize: Theme.iconSize
|
||||||
|
iconColor: Theme.primary
|
||||||
|
onClicked: root.openRuleModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
visible: root.activeWindows.length > 0
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Create rule for:")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
DankDropdown {
|
||||||
|
id: windowSelector
|
||||||
|
Layout.fillWidth: true
|
||||||
|
dropdownWidth: 400
|
||||||
|
compactMode: true
|
||||||
|
emptyText: I18n.tr("Select a window...")
|
||||||
|
options: root.activeWindows.map(w => {
|
||||||
|
const label = w.appId + (w.title ? " - " + w.title : "");
|
||||||
|
return label.length > 60 ? label.substring(0, 57) + "..." : label;
|
||||||
|
})
|
||||||
|
onValueChanged: value => {
|
||||||
|
if (!value)
|
||||||
|
return;
|
||||||
|
const index = options.indexOf(value);
|
||||||
|
if (index < 0 || index >= root.activeWindows.length)
|
||||||
|
return;
|
||||||
|
const window = root.activeWindows[index];
|
||||||
|
root.openRuleModal(window);
|
||||||
|
currentValue = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledRect {
|
||||||
|
id: warningBox
|
||||||
|
width: Math.min(650, parent.width - Theme.spacingL * 2)
|
||||||
|
height: warningSection.implicitHeight + Theme.spacingL * 2
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
|
||||||
|
readonly property bool showError: root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included
|
||||||
|
readonly property bool showSetup: !root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included
|
||||||
|
|
||||||
|
color: (showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.15) : "transparent"
|
||||||
|
border.color: (showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent"
|
||||||
|
border.width: 1
|
||||||
|
visible: (showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: warningSection
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingL
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "warning"
|
||||||
|
size: Theme.iconSize
|
||||||
|
color: Theme.warning
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width - Theme.iconSize - (fixButton.visible ? fixButton.width + Theme.spacingM : 0) - Theme.spacingM
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: warningBox.showSetup ? I18n.tr("Window Rules Not Configured") : I18n.tr("Window Rules Include Missing")
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.warning
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
readonly property string rulesFile: CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.conf"
|
||||||
|
text: warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile)
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
id: fixButton
|
||||||
|
visible: warningBox.showError || warningBox.showSetup
|
||||||
|
text: root.fixingInclude ? I18n.tr("Fixing...") : (warningBox.showSetup ? I18n.tr("Setup") : I18n.tr("Fix Now"))
|
||||||
|
backgroundColor: Theme.warning
|
||||||
|
textColor: Theme.background
|
||||||
|
enabled: !root.fixingInclude
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
onClicked: root.fixWindowRulesInclude()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledRect {
|
||||||
|
width: Math.min(650, parent.width - Theme.spacingL * 2)
|
||||||
|
height: rulesSection.implicitHeight + Theme.spacingL * 2
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: rulesSection
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingL
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "list"
|
||||||
|
size: Theme.iconSize
|
||||||
|
color: Theme.primary
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Rules (%1)").arg(root.windowRules?.length ?? 0)
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
visible: !root.windowRules || root.windowRules.length === 0
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: Theme.spacingM
|
||||||
|
}
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "select_window"
|
||||||
|
size: 40
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("No window rules configured")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Click + to add a rule for the focused window")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall - 1
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
opacity: 0.7
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: Theme.spacingM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: rulesListColumn
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
visible: root.windowRules && root.windowRules.length > 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
objectProp: "id"
|
||||||
|
values: root.windowRules || []
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: ruleDelegateItem
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
property bool held: ruleDragArea.pressed
|
||||||
|
property real originalY: y
|
||||||
|
|
||||||
|
readonly property string ruleIdRef: modelData.id
|
||||||
|
readonly property var liveRuleData: {
|
||||||
|
const rules = root.windowRules || [];
|
||||||
|
return rules.find(r => r.id === ruleIdRef) ?? modelData;
|
||||||
|
}
|
||||||
|
readonly property string displayName: {
|
||||||
|
const name = liveRuleData.name || "";
|
||||||
|
if (name)
|
||||||
|
return name;
|
||||||
|
const m = liveRuleData.matchCriteria || {};
|
||||||
|
return m.appId || m.title || I18n.tr("Unnamed Rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
width: rulesListColumn.width
|
||||||
|
height: ruleCard.height
|
||||||
|
z: held ? 2 : 1
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: ruleCard
|
||||||
|
width: parent.width
|
||||||
|
height: ruleContent.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: ruleDelegateItem.liveRuleData.enabled !== false ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, 0.4)
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: ruleContent
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingM
|
||||||
|
anchors.leftMargin: 28
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: ruleDelegateItem.displayName
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: ruleDelegateItem.liveRuleData.enabled !== false ? Theme.surfaceText : Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: {
|
||||||
|
const m = ruleDelegateItem.liveRuleData.matchCriteria || {};
|
||||||
|
let parts = [];
|
||||||
|
if (m.appId)
|
||||||
|
parts.push(m.appId);
|
||||||
|
if (m.title)
|
||||||
|
parts.push("title: " + m.title);
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : I18n.tr("No match criteria");
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 4
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
visible: {
|
||||||
|
const a = ruleDelegateItem.liveRuleData.actions || {};
|
||||||
|
return Object.keys(a).some(k => a[k] !== undefined && a[k] !== null && a[k] !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: {
|
||||||
|
const a = ruleDelegateItem.liveRuleData.actions || {};
|
||||||
|
const labels = {
|
||||||
|
"opacity": I18n.tr("Opacity"),
|
||||||
|
"openFloating": I18n.tr("Float"),
|
||||||
|
"openMaximized": I18n.tr("Maximize"),
|
||||||
|
"openMaximizedToEdges": I18n.tr("Max Edges"),
|
||||||
|
"openFullscreen": I18n.tr("Fullscreen"),
|
||||||
|
"openFocused": I18n.tr("Focus"),
|
||||||
|
"openOnOutput": I18n.tr("Output"),
|
||||||
|
"openOnWorkspace": I18n.tr("Workspace"),
|
||||||
|
"defaultColumnWidth": I18n.tr("Width"),
|
||||||
|
"defaultWindowHeight": I18n.tr("Height"),
|
||||||
|
"variableRefreshRate": I18n.tr("VRR"),
|
||||||
|
"blockOutFrom": I18n.tr("Block Out"),
|
||||||
|
"defaultColumnDisplay": I18n.tr("Display"),
|
||||||
|
"scrollFactor": I18n.tr("Scroll"),
|
||||||
|
"cornerRadius": I18n.tr("Radius"),
|
||||||
|
"clipToGeometry": I18n.tr("Clip"),
|
||||||
|
"tiledState": I18n.tr("Tiled"),
|
||||||
|
"minWidth": I18n.tr("Min W"),
|
||||||
|
"maxWidth": I18n.tr("Max W"),
|
||||||
|
"minHeight": I18n.tr("Min H"),
|
||||||
|
"maxHeight": I18n.tr("Max H"),
|
||||||
|
"tile": I18n.tr("Tile"),
|
||||||
|
"nofocus": I18n.tr("No Focus"),
|
||||||
|
"noborder": I18n.tr("No Border"),
|
||||||
|
"noshadow": I18n.tr("No Shadow"),
|
||||||
|
"nodim": I18n.tr("No Dim"),
|
||||||
|
"noblur": I18n.tr("No Blur"),
|
||||||
|
"noanim": I18n.tr("No Anim"),
|
||||||
|
"norounding": I18n.tr("No Round"),
|
||||||
|
"pin": I18n.tr("Pin"),
|
||||||
|
"opaque": I18n.tr("Opaque"),
|
||||||
|
"size": I18n.tr("Size"),
|
||||||
|
"move": I18n.tr("Move"),
|
||||||
|
"monitor": I18n.tr("Monitor"),
|
||||||
|
"workspace": I18n.tr("Workspace")
|
||||||
|
};
|
||||||
|
return Object.keys(a).filter(k => a[k] !== undefined && a[k] !== null && a[k] !== "").map(k => {
|
||||||
|
const val = a[k];
|
||||||
|
if (typeof val === "boolean")
|
||||||
|
return labels[k] || k;
|
||||||
|
return (labels[k] || k) + ": " + val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property string modelData
|
||||||
|
width: chipText.implicitWidth + Theme.spacingS * 2
|
||||||
|
height: 20
|
||||||
|
radius: 10
|
||||||
|
color: Theme.withAlpha(Theme.primary, 0.15)
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: chipText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData
|
||||||
|
font.pixelSize: Theme.fontSizeSmall - 2
|
||||||
|
color: Theme.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
buttonSize: 28
|
||||||
|
iconName: "edit"
|
||||||
|
iconSize: 16
|
||||||
|
backgroundColor: "transparent"
|
||||||
|
iconColor: Theme.surfaceVariantText
|
||||||
|
onClicked: root.editRule(ruleDelegateItem.liveRuleData)
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
id: deleteBtn
|
||||||
|
buttonSize: 28
|
||||||
|
iconName: "delete"
|
||||||
|
iconSize: 16
|
||||||
|
backgroundColor: "transparent"
|
||||||
|
iconColor: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: deleteArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.removeRule(ruleDelegateItem.ruleIdRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: ruleDragArea
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: 40
|
||||||
|
height: ruleCard.height
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.SizeVerCursor
|
||||||
|
drag.target: ruleDelegateItem.held ? ruleDelegateItem : undefined
|
||||||
|
drag.axis: Drag.YAxis
|
||||||
|
preventStealing: true
|
||||||
|
|
||||||
|
onPressed: {
|
||||||
|
ruleDelegateItem.z = 2;
|
||||||
|
ruleDelegateItem.originalY = ruleDelegateItem.y;
|
||||||
|
}
|
||||||
|
onReleased: {
|
||||||
|
ruleDelegateItem.z = 1;
|
||||||
|
if (!drag.active) {
|
||||||
|
ruleDelegateItem.y = ruleDelegateItem.originalY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spacing = Theme.spacingXS;
|
||||||
|
const itemH = ruleDelegateItem.height + spacing;
|
||||||
|
var newIndex = Math.round(ruleDelegateItem.y / itemH);
|
||||||
|
newIndex = Math.max(0, Math.min(newIndex, (root.windowRules?.length ?? 1) - 1));
|
||||||
|
if (newIndex !== ruleDelegateItem.index)
|
||||||
|
root.reorderRules(ruleDelegateItem.index, newIndex);
|
||||||
|
ruleDelegateItem.y = ruleDelegateItem.originalY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
x: Theme.spacingM - 2
|
||||||
|
y: (ruleCard.height / 2) - (size / 2)
|
||||||
|
name: "drag_indicator"
|
||||||
|
size: 18
|
||||||
|
color: Theme.outline
|
||||||
|
opacity: ruleDragArea.containsMouse || ruleDragArea.pressed ? 1 : 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on y {
|
||||||
|
enabled: !ruleDragArea.pressed && !ruleDragArea.drag.active
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Theme.standardEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,22 @@ Singleton {
|
|||||||
readonly property string outputsPath: hyprDmsDir + "/outputs.conf"
|
readonly property string outputsPath: hyprDmsDir + "/outputs.conf"
|
||||||
readonly property string layoutPath: hyprDmsDir + "/layout.conf"
|
readonly property string layoutPath: hyprDmsDir + "/layout.conf"
|
||||||
readonly property string cursorPath: hyprDmsDir + "/cursor.conf"
|
readonly property string cursorPath: hyprDmsDir + "/cursor.conf"
|
||||||
|
readonly property string windowrulesPath: hyprDmsDir + "/windowrules.conf"
|
||||||
|
|
||||||
property int _lastGapValue: -1
|
property int _lastGapValue: -1
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (CompositorService.isHyprland)
|
if (CompositorService.isHyprland) {
|
||||||
Qt.callLater(generateLayoutConfig);
|
Qt.callLater(generateLayoutConfig);
|
||||||
|
ensureWindowrulesConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindowrulesConfig() {
|
||||||
|
Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
console.warn("HyprlandService: Failed to ensure windowrules.conf:", output);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ Singleton {
|
|||||||
root.allWorkspaces = Object.values(newMap).sort((a, b) => a.idx - b.idx);
|
root.allWorkspaces = Object.values(newMap).sort((a, b) => a.idx - b.idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
validateProcess.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: fetchOutputs()
|
Component.onCompleted: fetchOutputs()
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
@@ -193,6 +197,16 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: ensureWindowrulesProcess
|
||||||
|
property string windowrulesPath: ""
|
||||||
|
|
||||||
|
onExited: exitCode => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
console.warn("NiriService: Failed to ensure windowrules.kdl, exit code:", exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DankSocket {
|
DankSocket {
|
||||||
id: eventStreamSocket
|
id: eventStreamSocket
|
||||||
path: root.socketPath
|
path: root.socketPath
|
||||||
@@ -1142,6 +1156,11 @@ Singleton {
|
|||||||
ensureCursorProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && [ ! -f "${cursorPath}" ] && touch "${cursorPath}" || true`];
|
ensureCursorProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && [ ! -f "${cursorPath}" ] && touch "${cursorPath}" || true`];
|
||||||
ensureCursorProcess.running = true;
|
ensureCursorProcess.running = true;
|
||||||
|
|
||||||
|
const windowrulesPath = niriDmsDir + "/windowrules.kdl";
|
||||||
|
ensureWindowrulesProcess.windowrulesPath = windowrulesPath;
|
||||||
|
ensureWindowrulesProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`];
|
||||||
|
ensureWindowrulesProcess.running = true;
|
||||||
|
|
||||||
configGenerationPending = false;
|
configGenerationPending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Singleton {
|
|||||||
property var polkitAuthModalLoader: null
|
property var polkitAuthModalLoader: null
|
||||||
property var bluetoothPairingModal: null
|
property var bluetoothPairingModal: null
|
||||||
property var networkInfoModal: null
|
property var networkInfoModal: null
|
||||||
|
property var windowRuleModalLoader: null
|
||||||
|
|
||||||
property var notepadSlideouts: []
|
property var notepadSlideouts: []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user