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

Compare commits

...

11 Commits

Author SHA1 Message Date
bbedward eda59b348c clipboard: react to changes 2026-01-27 22:50:28 -05:00
bbedward d19e81ffac clipboard: fix duplicate clear dialog 2026-01-27 22:41:01 -05:00
purian23 60c6872aec workflow: Update dms-git run times 2026-01-27 22:38:09 -05:00
bbedward a9cb2fe912 clipboard: fix hash duplication check, set isOwner for CopyFile 2026-01-27 22:35:20 -05:00
purian23 a168a8160c feat: appsDock Widget Overflow & Config Options 2026-01-27 21:15:33 -05:00
bbedward 78662f9613 window-rules: fix checkbox alignment 2026-01-27 19:44:17 -05:00
bbedward d9d7bb8dcc i18n: update settings search index 2026-01-27 19:39:29 -05:00
bbedward 3136f48b30 settings: make dock position match dankbar
fixes #1527
2026-01-27 19:33:32 -05:00
sin-1337 0c46711b01 Update Makefile (#1524)
Stop assuming the user's primary group matches their username.
2026-01-27 19:29:17 -05:00
bbedward 68159b5c41 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
2026-01-27 19:28:58 -05:00
purian23 6557d66f94 dms-git: It shall be beta 2026-01-27 17:56:08 -05:00
40 changed files with 5489 additions and 137 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ on:
required: false
default: ""
schedule:
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
jobs:
check-updates:
+1 -1
View File
@@ -12,7 +12,7 @@ on:
required: false
default: ""
schedule:
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
jobs:
check-updates:
+1
View File
@@ -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 v2 - omega stuff, GIF search, supa powerful
- dock on bar
- window rule manager, with IPC - #TODO verify RTL layout (niri only)
# 1.2.0
+2 -2
View File
@@ -58,10 +58,10 @@ install-completions:
install-systemd:
@echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR); fi
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR)/dms.service; fi
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
install-icon:
+48 -13
View File
@@ -240,6 +240,7 @@ func runClipCopy(cmd *cobra.Command, args []string) {
if err := copyFileToClipboard(filePath); err != nil {
log.Fatalf("copy file: %v", err)
}
fmt.Printf("Downloaded and copied: %s\n", filePath)
return
}
@@ -872,24 +873,58 @@ func downloadToTempFile(rawURL string) (string, error) {
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(rawURL)
if err != nil {
return "", fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: status %d", resp.StatusCode)
var data []byte
var contentType string
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
lastErr = fmt.Errorf("create request: %w", err)
continue
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "image/*,video/*,*/*")
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf("download (attempt %d): %w", attempt+1, err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
lastErr = fmt.Errorf("download failed (attempt %d): status %d", attempt+1, resp.StatusCode)
continue
}
data, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("read response (attempt %d): %w", attempt+1, err)
continue
}
contentType = resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx != -1 {
contentType = strings.TrimSpace(contentType[:idx])
}
lastErr = nil
break
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
if lastErr != nil {
return "", lastErr
}
contentType := resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx != -1 {
contentType = strings.TrimSpace(contentType[:idx])
if len(data) == 0 {
return "", fmt.Errorf("downloaded empty file")
}
if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") {
+336
View 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())
}
+2
View File
@@ -211,6 +211,7 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
{"windowrules.kdl", ""},
}
for _, cfg := range configs {
@@ -563,6 +564,7 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.conf", ""},
{"cursor.conf", ""},
{"windowrules.conf", ""},
}
for _, cfg := range configs {
@@ -38,6 +38,7 @@ bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
@@ -76,6 +76,7 @@ binds {
Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; }
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
// === Focus Navigation ===
Mod+Left { focus-column-left; }
+64 -3
View File
@@ -505,10 +505,10 @@ func computeHash(data []byte) uint64 {
}
func extractHash(data []byte) uint64 {
if len(data) < 8 {
if len(data) < 9 {
return 0
}
return binary.BigEndian.Uint64(data[len(data)-8:])
return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
}
func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
@@ -960,11 +960,21 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source
m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType}
m.sourceMutex.Unlock()
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
@@ -1533,16 +1543,56 @@ func (m *Manager) GetPinnedCount() int {
}
func (m *Manager) CopyFile(filePath string) error {
if _, err := os.Stat(filePath); err != nil {
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
cfg := m.getConfig()
if fileInfo.Size() > cfg.MaxEntrySize {
return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
exportedPath, err := m.ExportFileForFlatpak(filePath)
if err != nil {
exportedPath = filePath
}
fileURI := "file://" + exportedPath
if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok {
entry := Entry{
Data: imgData,
MimeType: imgMime,
Size: len(imgData),
Timestamp: time.Now(),
IsImage: true,
Preview: m.imagePreview(imgData, imgMime),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
} else {
entry := Entry{
Data: fileData,
MimeType: "text/uri-list",
Size: len(fileData),
Timestamp: time.Now(),
IsImage: false,
Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
}
m.updateState()
m.notifySubscribers()
m.post(func() {
if m.dataControlMgr == nil || m.dataDevice == nil {
log.Error("Data control manager or device not initialized")
@@ -1585,7 +1635,18 @@ func (m *Manager) CopyFile(filePath string) error {
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
@@ -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
}
@@ -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")
}
}
@@ -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)
}
@@ -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")
}
}
@@ -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
View 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
}
+3
View File
@@ -263,6 +263,9 @@ Singleton {
property bool clockCompactMode: false
property bool focusedWindowCompactMode: false
property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 0
property int barMaxVisibleRunningApps: 0
property bool barShowOverflowBadge: true
property bool keyboardLayoutNameCompactMode: false
property bool runningAppsCurrentWorkspace: false
property bool runningAppsGroupByApp: false
@@ -120,6 +120,9 @@ var SPEC = {
clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false },
runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 },
barShowOverflowBadge: { def: true },
keyboardLayoutNameCompactMode: { def: false },
runningAppsCurrentWorkspace: { def: false },
runningAppsGroupByApp: { def: false },
+13
View File
@@ -661,6 +661,18 @@ Item {
}
}
LazyLoader {
id: windowRuleModalLoader
active: false
Component.onCompleted: PopoutService.windowRuleModalLoader = windowRuleModalLoader
WindowRuleModal {
id: windowRuleModal
}
}
LazyLoader {
id: processListModalLoader
@@ -787,6 +799,7 @@ Item {
dankBarRepeater: dankBarRepeater
hyprlandOverviewLoader: hyprlandOverviewLoader
workspaceRenameModalLoader: workspaceRenameModalLoader
windowRuleModalLoader: windowRuleModalLoader
}
Variants {
+50
View File
@@ -1,6 +1,7 @@
import QtQuick
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Wayland
import qs.Common
import qs.Services
@@ -16,6 +17,7 @@ Item {
required property var dankBarRepeater
required property var hyprlandOverviewLoader
required property var workspaceRenameModalLoader
required property var windowRuleModalLoader
function getFirstBar() {
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
@@ -1402,4 +1404,52 @@ Item {
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"
}
}
@@ -30,7 +30,9 @@ Item {
onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
onTabChanged: tabName => modal.activeTab = tabName
onClearAllClicked: {
clearConfirmDialog.show(I18n.tr("Clear All History?"), I18n.tr("This will permanently delete all clipboard history."), function () {
const hasPinned = modal.pinnedCount > 0;
const message = hasPinned ? I18n.tr("This will delete all unpinned entries. %1 pinned entries will be kept.").arg(modal.pinnedCount) : I18n.tr("This will permanently delete all clipboard history.");
clearConfirmDialog.show(I18n.tr("Clear History?"), message, function () {
modal.clearAll();
modal.hide();
}, function () {});
@@ -239,20 +239,16 @@ DankModal {
function clearAll() {
const hasPinned = pinnedCount > 0;
const message = hasPinned ? I18n.tr("This will delete all unpinned entries. %1 pinned entries will be kept.").arg(pinnedCount) : I18n.tr("This will permanently delete all clipboard history.");
clearConfirmDialog.show(I18n.tr("Clear History?"), message, function () {
DMSService.sendRequest("clipboard.clearHistory", null, function (response) {
if (response.error) {
console.warn("ClipboardHistoryModal: Failed to clear history:", response.error);
return;
}
refreshClipboard();
if (hasPinned) {
ToastService.showInfo(I18n.tr("History cleared. %1 pinned entries kept.").arg(pinnedCount));
}
});
}, function () {});
DMSService.sendRequest("clipboard.clearHistory", null, function (response) {
if (response.error) {
console.warn("ClipboardHistoryModal: Failed to clear history:", response.error);
return;
}
refreshClipboard();
if (hasPinned) {
ToastService.showInfo(I18n.tr("History cleared. %1 pinned entries kept.").arg(pinnedCount));
}
});
}
function getEntryPreview(entry) {
@@ -288,6 +284,20 @@ DankModal {
modal: clipboardHistoryModal
}
Connections {
target: DMSService
function onClipboardStateUpdate(data) {
if (!clipboardHistoryModal.shouldBeVisible) {
return;
}
const newHistory = data.history || [];
internalEntries = newHistory;
pinnedEntries = newHistory.filter(e => e.pinned);
pinnedCount = pinnedEntries.length;
updateFilteredModel();
}
}
ConfirmModal {
id: clearConfirmDialog
confirmButtonText: I18n.tr("Clear All")
@@ -295,13 +305,18 @@ DankModal {
onVisibleChanged: {
if (visible) {
clipboardHistoryModal.shouldHaveFocus = false;
} else if (clipboardHistoryModal.shouldBeVisible) {
return;
}
Qt.callLater(function () {
if (!clipboardHistoryModal.shouldBeVisible) {
return;
}
clipboardHistoryModal.shouldHaveFocus = true;
clipboardHistoryModal.modalFocusScope.forceActiveFocus();
if (clipboardHistoryModal.contentLoader.item?.searchField) {
clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus();
}
}
});
}
}
@@ -93,31 +93,41 @@ Item {
}
}
Timer {
id: visibilityTimer
interval: 100
onTriggered: thumbnailImage.checkVisibility()
}
function checkVisibility() {
if (entryType !== "image" || listView.height <= 0 || isVisible) {
return;
}
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer;
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer;
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
if (nowVisible) {
isVisible = true;
tryLoadImage();
}
}
Connections {
target: listView
function checkVisibility() {
if (entryType !== "image" || listView.height <= 0) {
function onContentYChanged() {
if (thumbnailImage.isVisible || entryType !== "image") {
return;
}
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer;
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer;
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
if (nowVisible && !thumbnailImage.isVisible) {
thumbnailImage.isVisible = true;
thumbnailImage.tryLoadImage();
}
}
function onContentYChanged() {
checkVisibility();
visibilityTimer.restart();
}
function onHeightChanged() {
checkVisibility();
if (thumbnailImage.isVisible || entryType !== "image") {
return;
}
visibilityTimer.restart();
}
}
}
@@ -64,11 +64,18 @@ Popup {
return actions;
}
function executePluginAction(actionFunc) {
if (typeof actionFunc === "function") {
function executePluginAction(actionOrObj) {
var actionFunc = typeof actionOrObj === "function" ? actionOrObj : actionOrObj?.action;
var closeLauncher = typeof actionOrObj === "object" && actionOrObj?.closeLauncher;
if (typeof actionFunc === "function")
actionFunc();
if (closeLauncher) {
controller?.itemExecuted();
} else {
controller?.performSearch();
}
controller?.performSearch();
hide();
}
@@ -83,7 +90,7 @@ Popup {
type: "item",
icon: act.icon || "play_arrow",
text: act.text || act.name || "",
pluginAction: act.action
pluginAction: act
});
}
return items;
@@ -441,5 +441,22 @@ FocusScope {
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",
"tabIndex": 23,
"clipboardOnly": true
},
{
"id": "window_rules",
"text": I18n.tr("Window Rules"),
"icon": "select_window",
"tabIndex": 28,
"niriOnly": true
}
]
},
@@ -304,6 +311,8 @@ Rectangle {
return false;
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
return false;
if (item.niriOnly && !CompositorService.isNiri)
return false;
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
return false;
return true;
File diff suppressed because it is too large Load Diff
+201 -21
View File
@@ -31,6 +31,15 @@ Item {
property bool suppressShiftAnimation: false
property int pinnedAppCount: 0
property int maxVisibleApps: widgetData?.barMaxVisibleApps !== undefined ? widgetData.barMaxVisibleApps : SettingsData.barMaxVisibleApps
property int maxVisibleRunningApps: widgetData?.barMaxVisibleRunningApps !== undefined ? widgetData.barMaxVisibleRunningApps : SettingsData.barMaxVisibleRunningApps
property bool showOverflowBadge: widgetData?.barShowOverflowBadge !== undefined ? widgetData.barShowOverflowBadge : SettingsData.barShowOverflowBadge
property bool overflowExpanded: false
property int overflowItemCount: 0
onMaxVisibleAppsChanged: updateModel()
onMaxVisibleRunningAppsChanged: updateModel()
readonly property real effectiveBarThickness: {
if (barThickness > 0 && barSpacing > 0) {
return barThickness + barSpacing;
@@ -116,6 +125,12 @@ Item {
function onRunningAppsCurrentWorkspaceChanged() {
updateModel();
}
function onBarMaxVisibleAppsChanged() {
updateModel();
}
function onBarMaxVisibleRunningAppsChanged() {
updateModel();
}
}
Connections {
@@ -165,7 +180,51 @@ Item {
return null;
}
function updateModel() {
function createSeparator(key) {
return {
uniqueKey: key,
type: "separator",
appId: "__SEPARATOR__",
toplevel: null,
isPinned: false,
isRunning: false,
isInOverflow: false
};
}
function markAsOverflow(item) {
return {
uniqueKey: item.uniqueKey,
type: item.type,
appId: item.appId,
toplevel: item.toplevel,
isPinned: item.isPinned,
isRunning: item.isRunning,
windowCount: item.windowCount,
allWindows: item.allWindows,
isCoreApp: item.isCoreApp,
coreAppData: item.coreAppData,
isInOverflow: true
};
}
function markAsVisible(item) {
return {
uniqueKey: item.uniqueKey,
type: item.type,
appId: item.appId,
toplevel: item.toplevel,
isPinned: item.isPinned,
isRunning: item.isRunning,
windowCount: item.windowCount,
allWindows: item.allWindows,
isCoreApp: item.isCoreApp,
coreAppData: item.coreAppData,
isInOverflow: false
};
}
function buildBaseItems() {
const items = [];
const pinnedApps = [...(SessionData.barPinnedApps || [])];
_toplevelsUpdateTrigger;
@@ -235,7 +294,8 @@ Item {
windowCount: group.windows.length,
allWindows: group.windows,
isCoreApp: group.isCoreApp || false,
coreAppData: group.coreAppData || null
coreAppData: group.coreAppData || null,
isInOverflow: false
};
if (group.isPinned) {
@@ -248,20 +308,97 @@ Item {
pinnedGroups.forEach(item => items.push(item));
if (pinnedGroups.length > 0 && unpinnedGroups.length > 0) {
items.push({
uniqueKey: "separator_grouped",
type: "separator",
appId: "__SEPARATOR__",
toplevel: null,
isPinned: false,
isRunning: false
});
items.push(createSeparator("separator_grouped"));
}
unpinnedGroups.forEach(item => items.push(item));
root.pinnedAppCount = pinnedGroups.length;
dockItems = items;
return {
items,
pinnedCount: pinnedGroups.length,
runningCount: unpinnedGroups.length
};
}
function applyOverflow(baseResult) {
const { items } = baseResult;
const maxPinned = root.maxVisibleApps;
const maxRunning = root.maxVisibleRunningApps;
const pinnedItems = items.filter(i => i.type === "grouped" && i.isPinned);
const runningItems = items.filter(i => i.type === "grouped" && i.isRunning && !i.isPinned);
const pinnedOverflow = maxPinned > 0 && pinnedItems.length > maxPinned;
const runningOverflow = maxRunning > 0 && runningItems.length > maxRunning;
if (!pinnedOverflow && !runningOverflow) {
root.overflowItemCount = 0;
return items.map(i => markAsVisible(i));
}
const visiblePinnedKeys = new Set(pinnedOverflow ? pinnedItems.slice(0, maxPinned).map(i => i.uniqueKey) : pinnedItems.map(i => i.uniqueKey));
const visibleRunningKeys = new Set(runningOverflow ? runningItems.slice(0, maxRunning).map(i => i.uniqueKey) : runningItems.map(i => i.uniqueKey));
const overflowPinnedCount = pinnedOverflow ? pinnedItems.length - maxPinned : 0;
const overflowRunningCount = runningOverflow ? runningItems.length - maxRunning : 0;
const totalOverflow = overflowPinnedCount + overflowRunningCount;
root.overflowItemCount = totalOverflow;
const finalItems = [];
let addedSeparator = false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
switch (item.type) {
case "separator":
break;
case "grouped":
if (item.isPinned) {
if (visiblePinnedKeys.has(item.uniqueKey)) {
finalItems.push(markAsVisible(item));
} else {
finalItems.push(markAsOverflow(item));
}
} else if (item.isRunning) {
if (!addedSeparator && finalItems.length > 0) {
finalItems.push(createSeparator("separator_overflow"));
addedSeparator = true;
}
if (visibleRunningKeys.has(item.uniqueKey)) {
finalItems.push(markAsVisible(item));
} else {
finalItems.push(markAsOverflow(item));
}
}
break;
default:
finalItems.push(item);
break;
}
}
if (totalOverflow > 0) {
const toggleIndex = finalItems.findIndex(i => i.type === "separator");
const insertPos = toggleIndex >= 0 ? toggleIndex : finalItems.length;
finalItems.splice(insertPos, 0, {
uniqueKey: "overflow_toggle",
type: "overflow-toggle",
appId: "__OVERFLOW_TOGGLE__",
toplevel: null,
isPinned: false,
isRunning: false,
isInOverflow: false,
overflowCount: totalOverflow
});
}
return finalItems;
}
function updateModel() {
const baseResult = buildBaseItems();
dockItems = applyOverflow(baseResult);
}
Component.onCompleted: updateModel()
@@ -284,6 +421,10 @@ Item {
for (let i = 0; i < dockItems.length; i++) {
const item = dockItems[i];
const isInOverflow = item.isInOverflow === true;
if (isInOverflow && !root.overflowExpanded)
continue;
let itemSize = 0;
if (item.type === "separator") {
itemSize = 8;
@@ -411,13 +552,33 @@ Item {
Item {
id: delegateItem
property bool isSeparator: modelData.type === "separator"
readonly property bool isOverflowToggle: modelData.type === "overflow-toggle"
readonly property bool isInOverflow: modelData.isInOverflow === true
readonly property real visualSize: isSeparator ? 8 : ((widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) ? 24 : (24 + Theme.spacingXS + 120))
readonly property real visualWidth: root.isVertical ? root.barThickness : visualSize
readonly property real visualHeight: root.isVertical ? visualSize : root.barThickness
width: visualWidth
height: visualHeight
visible: !isInOverflow || root.overflowExpanded
opacity: (isInOverflow && !root.overflowExpanded) ? 0 : 1
scale: (isInOverflow && !root.overflowExpanded) ? 0.8 : 1
width: (isInOverflow && !root.overflowExpanded) ? 0 : visualWidth
height: (isInOverflow && !root.overflowExpanded) ? 0 : visualHeight
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
}
}
z: (dragHandler.dragging) ? 100 : 0
@@ -471,9 +632,27 @@ Item {
anchors.centerIn: parent
}
AppsDockOverflowButton {
visible: isOverflowToggle
anchors.centerIn: parent
width: delegateItem.visualWidth
height: delegateItem.visualHeight
iconSize: 24
overflowCount: modelData.overflowCount || 0
overflowExpanded: root.overflowExpanded
isVertical: root.isVertical
showBadge: root.showOverflowBadge
z: 10
onClicked: {
console.log("Overflow button clicked! Current state:", root.overflowExpanded);
root.overflowExpanded = !root.overflowExpanded;
console.log("New state:", root.overflowExpanded);
}
}
Item {
id: appItem
visible: !isSeparator
visible: !isSeparator && !isOverflowToggle
anchors.fill: parent
property bool isFocused: {
@@ -786,21 +965,22 @@ Item {
onEntered: {
root.hoveredItem = delegateItem;
if (isSeparator)
if (isSeparator || isOverflowToggle)
return;
tooltipLoader.active = true;
if (tooltipLoader.item) {
if (root.isVertical) {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2);
const globalPos = delegateItem.mapToGlobal(0, delegateItem.height / 2);
const screenX = root.parentScreen ? root.parentScreen.x : 0;
const screenY = root.parentScreen ? root.parentScreen.y : 0;
const relativeY = globalPos.y - screenY;
const tooltipX = root.axis?.edge === "left" ? (Theme.barHeight + (barConfig?.spacing ?? 4) + Theme.spacingXS) : (root.parentScreen.width - Theme.barHeight - (barConfig?.spacing ?? 4) - Theme.spacingXS);
const barThickness = root.effectiveBarThickness;
const spacing = barConfig?.spacing ?? 4;
const isLeft = root.axis?.edge === "left";
const adjustedY = relativeY + root.minTooltipY;
const finalX = screenX + tooltipX;
tooltipLoader.item.show(appItem.tooltipText, finalX, adjustedY, root.parentScreen, isLeft, !isLeft);
const tooltipOffset = barThickness + spacing + Theme.spacingM;
const tooltipX = isLeft ? tooltipOffset : (root.parentScreen.width - tooltipOffset);
const screenRelativeY = globalPos.y - screenY + root.barY;
tooltipLoader.item.show(appItem.tooltipText, screenX + tooltipX, screenRelativeY, root.parentScreen, isLeft, !isLeft);
} else {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height);
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
@@ -0,0 +1,76 @@
import QtQuick
import qs.Common
import qs.Widgets
Item {
id: root
property real iconSize: 24
property int overflowCount: 0
property bool overflowExpanded: false
property bool isVertical: false
property bool showBadge: true
signal clicked
Rectangle {
id: buttonBackground
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize
radius: Theme.cornerRadius
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, mouseArea.containsMouse ? 0.2 : 0.1)
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
}
}
DankIcon {
id: arrowIcon
anchors.centerIn: parent
size: root.iconSize * 0.6
name: "expand_more"
color: Theme.widgetIconColor
rotation: isVertical ? (overflowExpanded ? 180 : 0) : (overflowExpanded ? 90 : -90)
Behavior on rotation {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
}
}
}
}
Rectangle {
visible: overflowCount > 0 && !overflowExpanded && root.showBadge
anchors.right: buttonBackground.right
anchors.top: buttonBackground.top
anchors.rightMargin: -4
anchors.topMargin: -4
width: Math.max(18, badgeText.width + 8)
height: 18
radius: 9
color: Theme.primary
z: 10
StyledText {
id: badgeText
anchors.centerIn: parent
text: `+${overflowCount}`
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
color: Theme.onPrimary
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
+39 -36
View File
@@ -23,45 +23,48 @@ Item {
SettingsCard {
width: parent.width
iconName: "swap_vert"
title: I18n.tr("Dock Position")
title: I18n.tr("Position")
settingKey: "dockPosition"
SettingsButtonGroupRow {
text: I18n.tr("Position")
model: [I18n.tr("Top", "dock position option"), I18n.tr("Bottom", "dock position option"), I18n.tr("Left", "dock position option"), I18n.tr("Right", "dock position option")]
buttonPadding: Theme.spacingS
minButtonWidth: 44
textSize: Theme.fontSizeSmall
currentIndex: {
switch (SettingsData.dockPosition) {
case SettingsData.Position.Top:
return 0;
case SettingsData.Position.Bottom:
return 1;
case SettingsData.Position.Left:
return 2;
case SettingsData.Position.Right:
return 3;
default:
return 1;
Item {
width: parent.width
height: dockPositionButtonGroup.height
DankButtonGroup {
id: dockPositionButtonGroup
anchors.horizontalCenter: parent.horizontalCenter
model: [I18n.tr("Top"), I18n.tr("Bottom"), I18n.tr("Left"), I18n.tr("Right")]
currentIndex: {
switch (SettingsData.dockPosition) {
case SettingsData.Position.Top:
return 0;
case SettingsData.Position.Bottom:
return 1;
case SettingsData.Position.Left:
return 2;
case SettingsData.Position.Right:
return 3;
default:
return 1;
}
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
switch (index) {
case 0:
SettingsData.setDockPosition(SettingsData.Position.Top);
break;
case 1:
SettingsData.setDockPosition(SettingsData.Position.Bottom);
break;
case 2:
SettingsData.setDockPosition(SettingsData.Position.Left);
break;
case 3:
SettingsData.setDockPosition(SettingsData.Position.Right);
break;
onSelectionChanged: (index, selected) => {
if (!selected)
return;
switch (index) {
case 0:
SettingsData.setDockPosition(SettingsData.Position.Top);
break;
case 1:
SettingsData.setDockPosition(SettingsData.Position.Bottom);
break;
case 2:
SettingsData.setDockPosition(SettingsData.Position.Left);
break;
case 3:
SettingsData.setDockPosition(SettingsData.Position.Right);
break;
}
}
}
}
+28 -1
View File
@@ -419,7 +419,7 @@ Item {
"id": widget.id,
"enabled": widget.enabled
};
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "minimumWidth", "showSwap", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon"];
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "minimumWidth", "showSwap", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge"];
for (var i = 0; i < keys.length; i++) {
if (widget[keys[i]] !== undefined)
result[keys[i]] = widget[keys[i]];
@@ -530,6 +530,18 @@ Item {
setWidgetsForSection(sectionId, widgets);
}
function handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value) {
var widgets = getWidgetsForSection(sectionId).slice();
if (widgetIndex < 0 || widgetIndex >= widgets.length) {
setWidgetsForSection(sectionId, widgets);
return;
}
var newWidget = cloneWidgetData(widgets[widgetIndex]);
newWidget[settingName] = value;
widgets[widgetIndex] = newWidget;
setWidgetsForSection(sectionId, widgets);
}
function handleCompactModeChanged(sectionId, widgetId, value) {
var widgets = getWidgetsForSection(sectionId).slice();
for (var i = 0; i < widgets.length; i++) {
@@ -621,6 +633,12 @@ Item {
item.runningAppsCompactMode = widget.runningAppsCompactMode;
if (widget.keyboardLayoutNameCompactMode !== undefined)
item.keyboardLayoutNameCompactMode = widget.keyboardLayoutNameCompactMode;
if (widget.barMaxVisibleApps !== undefined)
item.barMaxVisibleApps = widget.barMaxVisibleApps;
if (widget.barMaxVisibleRunningApps !== undefined)
item.barMaxVisibleRunningApps = widget.barMaxVisibleRunningApps;
if (widget.barShowOverflowBadge !== undefined)
item.barShowOverflowBadge = widget.barShowOverflowBadge;
}
widgets.push(item);
});
@@ -897,6 +915,9 @@ Item {
onCompactModeChanged: (widgetId, value) => {
widgetsTab.handleCompactModeChanged(sectionId, widgetId, value);
}
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
}
}
}
@@ -952,6 +973,9 @@ Item {
onCompactModeChanged: (widgetId, value) => {
widgetsTab.handleCompactModeChanged(sectionId, widgetId, value);
}
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
}
}
}
@@ -1007,6 +1031,9 @@ Item {
onCompactModeChanged: (widgetId, value) => {
widgetsTab.handleCompactModeChanged(sectionId, widgetId, value);
}
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
}
}
}
}
@@ -30,13 +30,14 @@ Column {
signal privacySettingChanged(string sectionId, int widgetIndex, string settingName, bool value)
signal minimumWidthChanged(string sectionId, int widgetIndex, bool enabled)
signal showSwapChanged(string sectionId, int widgetIndex, bool enabled)
signal overflowSettingChanged(string sectionId, int widgetIndex, string settingName, var value)
function cloneWidgetData(widget) {
var result = {
"id": widget.id,
"enabled": widget.enabled
};
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "minimumWidth", "showSwap", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon"];
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "minimumWidth", "showSwap", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge"];
for (var i = 0; i < keys.length; i++) {
if (widget[keys[i]] !== undefined)
result[keys[i]] = widget[keys[i]];
@@ -408,7 +409,7 @@ Column {
Row {
spacing: Theme.spacingXS
visible: modelData.id === "clock" || modelData.id === "focusedWindow" || modelData.id === "runningApps" || modelData.id === "keyboard_layout_name"
visible: modelData.id === "clock" || modelData.id === "focusedWindow" || modelData.id === "runningApps" || modelData.id === "keyboard_layout_name" || modelData.id === "appsDock"
DankActionButton {
id: compactModeButton
@@ -508,6 +509,49 @@ Column {
}
}
DankActionButton {
id: overflowMenuButton
buttonSize: 28
visible: modelData.id === "appsDock"
iconName: "unfold_more"
iconSize: 16
iconColor: {
const maxApps = modelData.barMaxVisibleApps !== undefined ? modelData.barMaxVisibleApps : SettingsData.barMaxVisibleApps;
const maxRunning = modelData.barMaxVisibleRunningApps !== undefined ? modelData.barMaxVisibleRunningApps : SettingsData.barMaxVisibleRunningApps;
return (maxApps > 0 || maxRunning > 0) ? Theme.primary : Theme.outline;
}
onClicked: {
overflowContextMenu.widgetData = modelData;
overflowContextMenu.sectionId = root.sectionId;
overflowContextMenu.widgetIndex = index;
var buttonPos = overflowMenuButton.mapToItem(root, 0, 0);
var popupWidth = overflowContextMenu.width;
var popupHeight = overflowContextMenu.height;
var xPos = buttonPos.x - popupWidth - Theme.spacingS;
if (xPos < 0)
xPos = buttonPos.x + overflowMenuButton.width + Theme.spacingS;
var yPos = buttonPos.y - popupHeight / 2 + overflowMenuButton.height / 2;
if (yPos < 0) {
yPos = Theme.spacingS;
} else if (yPos + popupHeight > root.height) {
yPos = root.height - popupHeight - Theme.spacingS;
}
overflowContextMenu.x = xPos;
overflowContextMenu.y = yPos;
overflowContextMenu.open();
}
onEntered: {
sharedTooltip.show(I18n.tr("Overflow"), overflowMenuButton, 0, 0, "bottom");
}
onExited: {
sharedTooltip.hide();
}
}
Rectangle {
id: compactModeTooltip
width: tooltipText.contentWidth + Theme.spacingM * 2
@@ -1384,4 +1428,219 @@ Column {
}
}
}
Popup {
id: overflowContextMenu
property var widgetData: null
property string sectionId: ""
property int widgetIndex: -1
// Dynamically get current widget data from the items list
readonly property var currentWidgetData: (widgetIndex >= 0 && widgetIndex < root.items.length) ? root.items[widgetIndex] : widgetData
width: 280
height: overflowMenuColumn.implicitHeight + Theme.spacingS * 2
padding: 0
modal: true
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 0
}
contentItem: Item {
Column {
id: overflowMenuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Overflow")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
leftPadding: Theme.spacingS
}
Column {
width: parent.width
spacing: Theme.spacingXS
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Max Pinned Apps")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
width: 120
}
Row {
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
DankActionButton {
buttonSize: 24
iconName: "remove"
iconSize: 14
iconColor: Theme.outline
onClicked: {
var current = overflowContextMenu.currentWidgetData?.barMaxVisibleApps ?? SettingsData.barMaxVisibleApps;
var newVal = Math.max(0, current - 1);
root.overflowSettingChanged(overflowContextMenu.sectionId, overflowContextMenu.widgetIndex, "barMaxVisibleApps", newVal);
}
}
StyledText {
text: {
var val = overflowContextMenu.currentWidgetData?.barMaxVisibleApps ?? SettingsData.barMaxVisibleApps;
return val === 0 ? I18n.tr("All") : val;
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignHCenter
width: 30
}
DankActionButton {
buttonSize: 24
iconName: "add"
iconSize: 14
iconColor: Theme.outline
onClicked: {
var current = overflowContextMenu.currentWidgetData?.barMaxVisibleApps ?? SettingsData.barMaxVisibleApps;
var newVal = current + 1;
root.overflowSettingChanged(overflowContextMenu.sectionId, overflowContextMenu.widgetIndex, "barMaxVisibleApps", newVal);
}
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Max Running Apps")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
width: 120
}
Row {
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
DankActionButton {
buttonSize: 24
iconName: "remove"
iconSize: 14
iconColor: Theme.outline
onClicked: {
var current = overflowContextMenu.currentWidgetData?.barMaxVisibleRunningApps ?? SettingsData.barMaxVisibleRunningApps;
var newVal = Math.max(0, current - 1);
root.overflowSettingChanged(overflowContextMenu.sectionId, overflowContextMenu.widgetIndex, "barMaxVisibleRunningApps", newVal);
}
}
StyledText {
text: {
var val = overflowContextMenu.currentWidgetData?.barMaxVisibleRunningApps ?? SettingsData.barMaxVisibleRunningApps;
return val === 0 ? I18n.tr("All") : val;
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignHCenter
width: 30
}
DankActionButton {
buttonSize: 24
iconName: "add"
iconSize: 14
iconColor: Theme.outline
onClicked: {
var current = overflowContextMenu.currentWidgetData?.barMaxVisibleRunningApps ?? SettingsData.barMaxVisibleRunningApps;
var newVal = current + 1;
root.overflowSettingChanged(overflowContextMenu.sectionId, overflowContextMenu.widgetIndex, "barMaxVisibleRunningApps", newVal);
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
}
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
color: badgeToggleArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "notifications"
size: 16
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Show Badge")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
DankToggle {
id: badgeToggle
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 20
checked: overflowContextMenu.currentWidgetData?.barShowOverflowBadge ?? SettingsData.barShowOverflowBadge
onToggled: {
root.overflowSettingChanged(overflowContextMenu.sectionId, overflowContextMenu.widgetIndex, "barShowOverflowBadge", toggled);
}
}
MouseArea {
id: badgeToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: {
badgeToggle.checked = !badgeToggle.checked;
root.overflowSettingChanged(overflowContextMenu.sectionId, overflowContextMenu.widgetIndex, "barShowOverflowBadge", badgeToggle.checked);
}
}
}
}
}
}
}
}
@@ -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
}
}
}
}
}
}
}
}
}
}
+8 -3
View File
@@ -60,12 +60,13 @@ Singleton {
signal openUrlRequested(string url)
signal appPickerRequested(var data)
signal screensaverStateUpdate(var data)
signal clipboardStateUpdate(var data)
property bool capsLockState: false
property bool screensaverInhibited: false
property var screensaverInhibitors: []
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus"]
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard"]
Component.onCompleted: {
if (socketPath && socketPath.length > 0) {
@@ -392,6 +393,8 @@ Singleton {
screensaverStateUpdate(data);
} else if (service === "dbus") {
dbusSignalReceived(data.subscriptionId || "", data);
} else if (service === "clipboard") {
clipboardStateUpdate(data);
}
}
@@ -726,7 +729,8 @@ Singleton {
if (!response.error && response.result?.subscriptionId) {
dbusSubscriptions[response.result.subscriptionId] = true;
}
if (callback) callback(response);
if (callback)
callback(response);
});
}
@@ -737,7 +741,8 @@ Singleton {
if (!response.error) {
delete dbusSubscriptions[subscriptionId];
}
if (callback) callback(response);
if (callback)
callback(response);
});
}
+11 -1
View File
@@ -14,12 +14,22 @@ Singleton {
readonly property string outputsPath: hyprDmsDir + "/outputs.conf"
readonly property string layoutPath: hyprDmsDir + "/layout.conf"
readonly property string cursorPath: hyprDmsDir + "/cursor.conf"
readonly property string windowrulesPath: hyprDmsDir + "/windowrules.conf"
property int _lastGapValue: -1
Component.onCompleted: {
if (CompositorService.isHyprland)
if (CompositorService.isHyprland) {
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 {
+19
View File
@@ -52,6 +52,10 @@ Singleton {
root.allWorkspaces = Object.values(newMap).sort((a, b) => a.idx - b.idx);
}
function validate() {
validateProcess.running = true;
}
Component.onCompleted: fetchOutputs()
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 {
id: eventStreamSocket
path: root.socketPath
@@ -1142,6 +1156,11 @@ Singleton {
ensureCursorProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && [ ! -f "${cursorPath}" ] && touch "${cursorPath}" || 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;
}
+1
View File
@@ -33,6 +33,7 @@ Singleton {
property var polkitAuthModalLoader: null
property var bluetoothPairingModal: null
property var networkInfoModal: null
property var windowRuleModalLoader: null
property var notepadSlideouts: []
+1 -1
View File
@@ -1 +1 @@
v1.4-unstable
v1.4-beta
@@ -1254,20 +1254,6 @@
],
"icon": "apps"
},
{
"section": "dockPosition",
"label": "Dock Position",
"tabIndex": 5,
"category": "Dock",
"keywords": [
"dock",
"launcher bar",
"panel",
"position",
"taskbar"
],
"icon": "swap_vert"
},
{
"section": "dockVisibility",
"label": "Dock Visibility",
@@ -1418,6 +1404,56 @@
],
"icon": "apps"
},
{
"section": "dockMaxVisibleApps",
"label": "Max Pinned Apps (0 = Unlimited)",
"tabIndex": 5,
"category": "Dock",
"keywords": [
"apps",
"dock",
"launcher bar",
"limit",
"max",
"overflow",
"pinned",
"taskbar",
"unlimited)"
]
},
{
"section": "dockMaxVisibleRunningApps",
"label": "Max Running Apps (0 = Unlimited)",
"tabIndex": 5,
"category": "Dock",
"keywords": [
"active",
"apps",
"dock",
"launcher bar",
"limit",
"max",
"open",
"overflow",
"running",
"taskbar",
"unlimited)",
"windows"
]
},
{
"section": "dockPosition",
"label": "Position",
"tabIndex": 5,
"category": "Dock",
"keywords": [
"dock",
"launcher bar",
"position",
"taskbar"
],
"icon": "swap_vert"
},
{
"section": "showDock",
"label": "Show Dock",
@@ -1463,6 +1499,31 @@
"taskbar"
]
},
{
"section": "dockShowOverflowBadge",
"label": "Show Overflow Badge Count",
"tabIndex": 5,
"category": "Dock",
"keywords": [
"active",
"badge",
"count",
"displays",
"dock",
"indicator",
"launcher bar",
"monitor",
"monitors",
"output",
"outputs",
"overflow",
"screen",
"screens",
"show",
"taskbar"
],
"description": "Displays count when overflow is active"
},
{
"section": "dockOpenOnOverview",
"label": "Show on Overview",
@@ -2199,6 +2260,32 @@
"icon": "contrast",
"description": "Use light theme instead of dark theme"
},
{
"section": "controlCenterTileColorMode",
"label": "Control Center Tile Color",
"tabIndex": 10,
"category": "Theme & Colors",
"keywords": [
"active",
"appearance",
"background",
"button",
"center",
"color",
"colors",
"colour",
"control",
"hue",
"icon",
"look",
"scheme",
"style",
"theme",
"tile",
"tint"
],
"description": "Active tile background and icon color"
},
{
"section": "cornerRadius",
"label": "Corner Radius",
@@ -5596,5 +5683,17 @@
"widgets"
],
"icon": "widgets"
},
{
"section": "_tab_28",
"label": "Window Rules",
"tabIndex": 28,
"category": "Settings",
"keywords": [
"rules",
"settings",
"window"
],
"icon": "select_window"
}
]