From d20aa3b80aa9898391e2eabc742ca87e688e422e Mon Sep 17 00:00:00 2001 From: purian23 Date: Wed, 3 Jun 2026 08:59:51 -0400 Subject: [PATCH] feat(window-rules): view & convert external rules to DMS - Read and convert external compositor rules into editable DMS rules - Preserve niri multi-match rules and add match editor - niri background-effect (blur/xray/noise/saturation) support --- .../windowrules/providers/niri_parser.go | 223 +++++++-- core/internal/windowrules/types.go | 17 +- quickshell/Modals/WindowRuleModal.qml | 450 ++++++++++++++++- .../Modules/Settings/WindowRulesTab.qml | 455 ++++++++++++++++-- 4 files changed, 1039 insertions(+), 106 deletions(-) diff --git a/core/internal/windowrules/providers/niri_parser.go b/core/internal/windowrules/providers/niri_parser.go index 5bb5ec45..5b930741 100644 --- a/core/internal/windowrules/providers/niri_parser.go +++ b/core/internal/windowrules/providers/niri_parser.go @@ -14,6 +14,18 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" ) +type NiriMatch struct { + AppID string + Title string + IsFloating *bool + IsActive *bool + IsFocused *bool + IsActiveInColumn *bool + IsWindowCastTarget *bool + IsUrgent *bool + AtStartup *bool +} + type NiriWindowRule struct { MatchAppID string MatchTitle string @@ -24,6 +36,7 @@ type NiriWindowRule struct { MatchIsWindowCastTarget *bool MatchIsUrgent *bool MatchAtStartup *bool + Matches []NiriMatch Opacity *float64 OpenFloating *bool OpenMaximized *bool @@ -50,6 +63,10 @@ type NiriWindowRule struct { FocusRingOff *bool BorderOff *bool DrawBorderWithBg *bool + BgBlur *bool + BgXray *bool + BgNoise *float64 + BgSaturation *float64 Source string } @@ -191,7 +208,7 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) { switch childName { case "match": - p.parseMatchNode(child, &rule) + rule.Matches = append(rule.Matches, p.parseMatchNode(child)) case "opacity": if len(child.Arguments) > 0 { val := child.Arguments[0].ResolvedValue() @@ -297,9 +314,24 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) { case "draw-border-with-background": b := p.parseBoolArg(child) rule.DrawBorderWithBg = &b + case "background-effect": + p.parseBackgroundEffectNode(child, &rule) } } + if len(rule.Matches) > 0 { + first := rule.Matches[0] + rule.MatchAppID = first.AppID + rule.MatchTitle = first.Title + rule.MatchIsFloating = first.IsFloating + rule.MatchIsActive = first.IsActive + rule.MatchIsFocused = first.IsFocused + rule.MatchIsActiveInColumn = first.IsActiveInColumn + rule.MatchIsWindowCastTarget = first.IsWindowCastTarget + rule.MatchIsUrgent = first.IsUrgent + rule.MatchAtStartup = first.AtStartup + } + p.rules = append(p.rules, rule) } @@ -326,45 +358,47 @@ func (p *NiriRulesParser) parseSizeNode(node *document.Node) string { return "" } -func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) { +func (p *NiriRulesParser) parseMatchNode(node *document.Node) NiriMatch { + m := NiriMatch{} if node.Properties == nil { - return + return m } if val, ok := node.Properties.Get("app-id"); ok { - rule.MatchAppID = val.ValueString() + m.AppID = val.ValueString() } if val, ok := node.Properties.Get("title"); ok { - rule.MatchTitle = val.ValueString() + m.Title = val.ValueString() } if val, ok := node.Properties.Get("is-floating"); ok { b := val.ValueString() == "true" - rule.MatchIsFloating = &b + m.IsFloating = &b } if val, ok := node.Properties.Get("is-active"); ok { b := val.ValueString() == "true" - rule.MatchIsActive = &b + m.IsActive = &b } if val, ok := node.Properties.Get("is-focused"); ok { b := val.ValueString() == "true" - rule.MatchIsFocused = &b + m.IsFocused = &b } if val, ok := node.Properties.Get("is-active-in-column"); ok { b := val.ValueString() == "true" - rule.MatchIsActiveInColumn = &b + m.IsActiveInColumn = &b } if val, ok := node.Properties.Get("is-window-cast-target"); ok { b := val.ValueString() == "true" - rule.MatchIsWindowCastTarget = &b + m.IsWindowCastTarget = &b } if val, ok := node.Properties.Get("is-urgent"); ok { b := val.ValueString() == "true" - rule.MatchIsUrgent = &b + m.IsUrgent = &b } if val, ok := node.Properties.Get("at-startup"); ok { b := val.ValueString() == "true" - rule.MatchAtStartup = &b + m.AtStartup = &b } + return m } func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) { @@ -385,6 +419,45 @@ func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowR } } +func (p *NiriRulesParser) parseBackgroundEffectNode(node *document.Node, rule *NiriWindowRule) { + if node.Children == nil { + return + } + + for _, child := range node.Children { + switch child.Name.String() { + case "blur": + b := p.parseBoolArg(child) + rule.BgBlur = &b + case "xray": + b := p.parseBoolArg(child) + rule.BgXray = &b + case "noise": + if f, ok := p.parseFloatArg(child); ok { + rule.BgNoise = &f + } + case "saturation": + if f, ok := p.parseFloatArg(child); ok { + rule.BgSaturation = &f + } + } + } +} + +func (p *NiriRulesParser) parseFloatArg(node *document.Node) (float64, bool) { + if len(node.Arguments) == 0 { + return 0, false + } + val := node.Arguments[0].ResolvedValue() + switch v := val.(type) { + case float64: + return v, true + case int64: + return float64(v), true + } + return 0, false +} + func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) { if node.Children == nil { return @@ -461,6 +534,27 @@ func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) { }, nil } +func convertNiriMatches(matches []NiriMatch) []windowrules.MatchCriteria { + if len(matches) == 0 { + return nil + } + result := make([]windowrules.MatchCriteria, 0, len(matches)) + for _, m := range matches { + result = append(result, windowrules.MatchCriteria{ + AppID: m.AppID, + Title: m.Title, + IsFloating: m.IsFloating, + IsActive: m.IsActive, + IsFocused: m.IsFocused, + IsActiveInColumn: m.IsActiveInColumn, + IsWindowCastTarget: m.IsWindowCastTarget, + IsUrgent: m.IsUrgent, + AtStartup: m.AtStartup, + }) + } + return result +} + func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule { result := make([]windowrules.WindowRule, 0, len(niriRules)) for i, nr := range niriRules { @@ -479,6 +573,7 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win IsUrgent: nr.MatchIsUrgent, AtStartup: nr.MatchAtStartup, }, + Matches: convertNiriMatches(nr.Matches), Actions: windowrules.Actions{ Opacity: nr.Opacity, OpenFloating: nr.OpenFloating, @@ -506,6 +601,10 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win FocusRingOff: nr.FocusRingOff, BorderOff: nr.BorderOff, DrawBorderWithBg: nr.DrawBorderWithBg, + BackgroundBlur: nr.BgBlur, + BackgroundXray: nr.BgXray, + BackgroundNoise: nr.BgNoise, + BackgroundSaturation: nr.BgSaturation, }, } result = append(result, wr) @@ -684,6 +783,7 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) IsUrgent: nr.MatchIsUrgent, AtStartup: nr.MatchAtStartup, }, + Matches: convertNiriMatches(nr.Matches), Actions: windowrules.Actions{ Opacity: nr.Opacity, OpenFloating: nr.OpenFloating, @@ -711,6 +811,10 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) FocusRingOff: nr.FocusRingOff, BorderOff: nr.BorderOff, DrawBorderWithBg: nr.DrawBorderWithBg, + BackgroundBlur: nr.BgBlur, + BackgroundXray: nr.BgXray, + BackgroundNoise: nr.BgNoise, + BackgroundSaturation: nr.BgSaturation, }, } @@ -740,44 +844,54 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644) } +func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) { + 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)) + } + if len(matchProps) == 0 { + return "", false + } + return " match " + strings.Join(matchProps, " "), true +} + 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)) + matches := rule.Matches + if len(matches) == 0 { + matches = []windowrules.MatchCriteria{rule.MatchCriteria} + } + for _, m := range matches { + if line, ok := formatNiriMatchLine(m); ok { + lines = append(lines, line) } - 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 @@ -858,10 +972,31 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string { lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg)) } + if a.BackgroundBlur != nil || a.BackgroundXray != nil || a.BackgroundNoise != nil || a.BackgroundSaturation != nil { + lines = append(lines, " background-effect {") + if a.BackgroundBlur != nil { + lines = append(lines, fmt.Sprintf(" blur %t", *a.BackgroundBlur)) + } + if a.BackgroundXray != nil { + lines = append(lines, fmt.Sprintf(" xray %t", *a.BackgroundXray)) + } + if a.BackgroundNoise != nil { + lines = append(lines, fmt.Sprintf(" noise %s", formatFloat(*a.BackgroundNoise))) + } + if a.BackgroundSaturation != nil { + lines = append(lines, fmt.Sprintf(" saturation %s", formatFloat(*a.BackgroundSaturation))) + } + lines = append(lines, " }") + } + lines = append(lines, "}") return strings.Join(lines, "\n") } +func formatFloat(f float64) string { + return strconv.FormatFloat(f, 'f', -1, 64) +} + func formatSizeProperty(name, value string) string { parts := strings.SplitN(value, " ", 2) if len(parts) == 2 { diff --git a/core/internal/windowrules/types.go b/core/internal/windowrules/types.go index 512acada..d6b60f99 100644 --- a/core/internal/windowrules/types.go +++ b/core/internal/windowrules/types.go @@ -43,6 +43,10 @@ type Actions struct { FocusRingOff *bool `json:"focusRingOff,omitempty"` BorderOff *bool `json:"borderOff,omitempty"` DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"` + BackgroundBlur *bool `json:"backgroundBlur,omitempty"` + BackgroundXray *bool `json:"backgroundXray,omitempty"` + BackgroundNoise *float64 `json:"backgroundNoise,omitempty"` + BackgroundSaturation *float64 `json:"backgroundSaturation,omitempty"` Size string `json:"size,omitempty"` Move string `json:"move,omitempty"` Monitor string `json:"monitor,omitempty"` @@ -62,12 +66,13 @@ type Actions struct { } 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"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + Enabled bool `json:"enabled"` + MatchCriteria MatchCriteria `json:"matchCriteria"` + Matches []MatchCriteria `json:"matches,omitempty"` + Actions Actions `json:"actions"` + Source string `json:"source,omitempty"` } type DMSRulesStatus struct { diff --git a/quickshell/Modals/WindowRuleModal.qml b/quickshell/Modals/WindowRuleModal.qml index 6c9583aa..b5f67118 100644 --- a/quickshell/Modals/WindowRuleModal.qml +++ b/quickshell/Modals/WindowRuleModal.qml @@ -20,6 +20,10 @@ FloatingWindow { readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2 readonly property int sectionSpacing: Theme.spacingL + ListModel { + id: extraMatchModel + } + objectName: "windowRuleModal" title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule") minimumSize: Qt.size(500, 600) @@ -31,6 +35,18 @@ FloatingWindow { nameInput.text = ""; appIdInput.text = ""; titleInput.text = ""; + extraMatchModel.clear(); + condFloating.triState = 0; + condActive.triState = 0; + condFocused.triState = 0; + condActiveInColumn.triState = 0; + condCastTarget.triState = 0; + condUrgent.triState = 0; + condAtStartup.triState = 0; + condXwayland.triState = 0; + condFullscreen.triState = 0; + condPinned.triState = 0; + condInitialised.triState = 0; opacityEnabled.checked = false; opacitySlider.value = 100; floatingToggle.checked = false; @@ -52,6 +68,12 @@ FloatingWindow { clipToGeometryToggle.checked = false; tiledStateToggle.checked = false; drawBorderBgToggle.checked = false; + blurCond.triState = 0; + xrayCond.triState = 0; + noiseEnabled.checked = false; + noiseSlider.value = 5; + saturationEnabled.checked = false; + saturationSlider.value = 100; minWidthInput.text = ""; maxWidthInput.text = ""; minHeightInput.text = ""; @@ -84,18 +106,39 @@ FloatingWindow { Qt.callLater(() => nameInput.forceActiveFocus()); } - function showEdit(rule) { - if (!rule) { - show(); - return; - } - editingRule = rule; - resetForm(); + function triFromBool(v) { + if (v === true) + return 1; + if (v === false) + return 2; + return 0; + } + function populateForm(rule) { nameInput.text = rule.name || ""; - const match = rule.matchCriteria || {}; + const matchList = (rule.matches && rule.matches.length > 0) ? rule.matches : [rule.matchCriteria || {}]; + const match = matchList[0] || {}; appIdInput.text = match.appId || ""; titleInput.text = match.title || ""; + extraMatchModel.clear(); + for (let i = 1; i < matchList.length; i++) { + extraMatchModel.append({ + "rowAppId": matchList[i].appId || "", + "rowTitle": matchList[i].title || "" + }); + } + + condFloating.triState = triFromBool(match.isFloating); + condActive.triState = triFromBool(match.isActive); + condFocused.triState = triFromBool(match.isFocused); + condActiveInColumn.triState = triFromBool(match.isActiveInColumn); + condCastTarget.triState = triFromBool(match.isWindowCastTarget); + condUrgent.triState = triFromBool(match.isUrgent); + condAtStartup.triState = triFromBool(match.atStartup); + condXwayland.triState = triFromBool(match.xwayland); + condFullscreen.triState = triFromBool(match.fullscreen); + condPinned.triState = triFromBool(match.pinned); + condInitialised.triState = triFromBool(match.initialised); const actions = rule.actions || {}; const hasOpacity = actions.opacity !== undefined && actions.opacity !== null; @@ -131,6 +174,15 @@ FloatingWindow { drawBorderBgToggle.checked = actions.drawBorderWithBackground || false; + xrayCond.triState = triFromBool(actions.backgroundXray); + blurCond.triState = triFromBool(actions.backgroundBlur); + const hasNoise = actions.backgroundNoise !== undefined && actions.backgroundNoise !== null; + noiseEnabled.checked = hasNoise; + noiseSlider.value = hasNoise ? Math.round(actions.backgroundNoise * 100) : 5; + const hasSaturation = actions.backgroundSaturation !== undefined && actions.backgroundSaturation !== null; + saturationEnabled.checked = hasSaturation; + saturationSlider.value = hasSaturation ? Math.round(actions.backgroundSaturation * 100) : 100; + minWidthInput.text = actions.minWidth !== undefined ? String(actions.minWidth) : ""; maxWidthInput.text = actions.maxWidth !== undefined ? String(actions.maxWidth) : ""; minHeightInput.text = actions.minHeight !== undefined ? String(actions.minHeight) : ""; @@ -150,7 +202,28 @@ FloatingWindow { moveInput.text = actions.move || ""; monitorInput.text = actions.monitor || ""; hyprWorkspaceInput.text = actions.workspace || ""; + } + function showEdit(rule) { + if (!rule) { + show(); + return; + } + editingRule = rule; + resetForm(); + populateForm(rule); + visible = true; + Qt.callLater(() => nameInput.forceActiveFocus()); + } + + function showCopy(rule) { + if (!rule) { + show(); + return; + } + editingRule = null; + resetForm(); + populateForm(rule); visible = true; Qt.callLater(() => nameInput.forceActiveFocus()); } @@ -161,6 +234,13 @@ FloatingWindow { targetWindow = null; } + function applyCond(obj, key, triState) { + if (triState === 1) + obj[key] = true; + else if (triState === 2) + obj[key] = false; + } + function submitAndClose() { const matchCriteria = {}; if (appIdInput.text.trim()) @@ -168,6 +248,38 @@ FloatingWindow { if (titleInput.text.trim()) matchCriteria.title = titleInput.text.trim(); + applyCond(matchCriteria, "isFloating", condFloating.triState); + if (isNiri) { + applyCond(matchCriteria, "isActive", condActive.triState); + applyCond(matchCriteria, "isFocused", condFocused.triState); + applyCond(matchCriteria, "isActiveInColumn", condActiveInColumn.triState); + applyCond(matchCriteria, "isWindowCastTarget", condCastTarget.triState); + applyCond(matchCriteria, "isUrgent", condUrgent.triState); + applyCond(matchCriteria, "atStartup", condAtStartup.triState); + } + if (isHyprland) { + applyCond(matchCriteria, "xwayland", condXwayland.triState); + applyCond(matchCriteria, "fullscreen", condFullscreen.triState); + applyCond(matchCriteria, "pinned", condPinned.triState); + applyCond(matchCriteria, "initialised", condInitialised.triState); + } + + const matches = []; + if (Object.keys(matchCriteria).length > 0) + matches.push(matchCriteria); + if (isNiri) { + for (let i = 0; i < extraMatchModel.count; i++) { + const row = extraMatchModel.get(i); + const m = {}; + if ((row.rowAppId || "").trim()) + m.appId = row.rowAppId.trim(); + if ((row.rowTitle || "").trim()) + m.title = row.rowTitle.trim(); + if (Object.keys(m).length > 0) + matches.push(m); + } + } + const actions = {}; if (opacityEnabled.checked) @@ -206,6 +318,14 @@ FloatingWindow { actions.tiledState = true; if (drawBorderBgToggle.checked && isNiri) actions.drawBorderWithBackground = true; + if (isNiri) { + applyCond(actions, "backgroundBlur", blurCond.triState); + applyCond(actions, "backgroundXray", xrayCond.triState); + } + if (noiseEnabled.checked && isNiri) + actions.backgroundNoise = noiseSlider.value / 100; + if (saturationEnabled.checked && isNiri) + actions.backgroundSaturation = saturationSlider.value / 100; const minW = parseInt(minWidthInput.text); const maxW = parseInt(maxWidthInput.text); @@ -260,6 +380,8 @@ FloatingWindow { actions: actions, enabled: true }; + if (isNiri && extraMatchModel.count > 0) + ruleData.matches = matches; submitting = true; @@ -369,6 +491,61 @@ FloatingWindow { border.width: hasFocus ? 2 : 1 } + // Tri-state toggle: 0 = unset (Inherit/Any), 1 = true, 2 = false + component MatchCond: Rectangle { + id: mc + property string label: "" + property int triState: 0 + property string unsetLabel: I18n.tr("Any") + property bool readOnly: false + readonly property var stateText: [mc.unsetLabel, "true", "false"] + readonly property var stateColor: [Theme.surfaceVariantText, Theme.primary, Theme.error] + + width: condRow.implicitWidth + Theme.spacingM * 2 + height: root.inputFieldHeight + radius: Theme.cornerRadius + color: Theme.surfaceHover + border.width: 1 + border.color: mc.triState === 0 ? Theme.outlineStrong : mc.stateColor[mc.triState] + opacity: mc.readOnly ? 0.4 : 1 + + Row { + id: condRow + anchors.centerIn: parent + spacing: Theme.spacingXS + + StyledText { + text: mc.label + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + width: stateBadge.implicitWidth + Theme.spacingS * 2 + height: 18 + radius: 9 + color: Theme.withAlpha(mc.stateColor[mc.triState], 0.15) + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: stateBadge + anchors.centerIn: parent + text: mc.stateText[mc.triState] + font.pixelSize: Theme.fontSizeSmall - 2 + color: mc.stateColor[mc.triState] + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: root.visible && !mc.readOnly + onClicked: mc.triState = (mc.triState + 1) % 3 + } + } + FocusScope { anchors.fill: parent focus: true @@ -514,6 +691,176 @@ FloatingWindow { } } + StyledText { + width: parent.width + visible: root.isNiri + text: I18n.tr("The rule applies to any window matching one of these.") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Repeater { + model: extraMatchModel + + delegate: Row { + width: parent.width + spacing: Theme.spacingS + + InputField { + width: (parent.width - removeMatchBtn.width - Theme.spacingS * 2) / 2 + hasFocus: extraAppId.activeFocus + DankTextField { + id: extraAppId + anchors.fill: parent + font.pixelSize: Theme.fontSizeSmall + textColor: Theme.surfaceText + placeholderText: root.isNiri ? I18n.tr("App ID regex") : I18n.tr("Class regex") + backgroundColor: "transparent" + enabled: root.visible + text: rowAppId + onTextEdited: extraMatchModel.setProperty(index, "rowAppId", text) + } + } + + InputField { + width: (parent.width - removeMatchBtn.width - Theme.spacingS * 2) / 2 + hasFocus: extraTitle.activeFocus + DankTextField { + id: extraTitle + anchors.fill: parent + font.pixelSize: Theme.fontSizeSmall + textColor: Theme.surfaceText + placeholderText: I18n.tr("Title regex (optional)") + backgroundColor: "transparent" + enabled: root.visible + text: rowTitle + onTextEdited: extraMatchModel.setProperty(index, "rowTitle", text) + } + } + + DankActionButton { + id: removeMatchBtn + width: root.inputFieldHeight + height: root.inputFieldHeight + circular: false + iconName: "close" + iconSize: 16 + iconColor: Theme.surfaceVariantText + tooltipText: I18n.tr("Remove match") + tooltipSide: "left" + onClicked: extraMatchModel.remove(index) + } + } + } + + Item { + width: parent.width + height: root.inputFieldHeight + visible: root.isNiri + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: "add" + size: 18 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Add match") + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: extraMatchModel.append({ + "rowAppId": "", + "rowTitle": "" + }) + } + } + + SectionHeader { + title: I18n.tr("Match Conditions") + } + + StyledText { + width: parent.width + text: I18n.tr("Optional state-based conditions applied to the first match.") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Flow { + width: parent.width + spacing: Theme.spacingS + + MatchCond { + id: condFloating + label: I18n.tr("Floating") + } + MatchCond { + id: condActive + label: I18n.tr("Active") + visible: isNiri + } + MatchCond { + id: condFocused + label: I18n.tr("Focused") + visible: isNiri + } + MatchCond { + id: condActiveInColumn + label: I18n.tr("Active in Column") + visible: isNiri + } + MatchCond { + id: condCastTarget + label: I18n.tr("Cast Target") + visible: isNiri + } + MatchCond { + id: condUrgent + label: I18n.tr("Urgent") + visible: isNiri + } + MatchCond { + id: condAtStartup + label: I18n.tr("At Startup") + visible: isNiri + } + MatchCond { + id: condXwayland + label: I18n.tr("XWayland") + visible: isHyprland + } + MatchCond { + id: condFullscreen + label: I18n.tr("Fullscreen") + visible: isHyprland + } + MatchCond { + id: condPinned + label: I18n.tr("Pinned") + visible: isHyprland + } + MatchCond { + id: condInitialised + label: I18n.tr("Initialised") + visible: isHyprland + } + } + SectionHeader { title: I18n.tr("Window Opening") } @@ -682,6 +1029,7 @@ FloatingWindow { DankSlider { id: opacitySlider + wheelEnabled: false width: parent.width - 100 minimum: 10 maximum: 100 @@ -710,7 +1058,7 @@ FloatingWindow { } CheckboxRow { id: drawBorderBgToggle - label: I18n.tr("Border with BG") + label: I18n.tr("Border with Background") } } @@ -777,6 +1125,7 @@ FloatingWindow { DankSlider { id: scrollFactorSlider + wheelEnabled: false width: parent.width - 120 minimum: 10 maximum: 200 @@ -798,6 +1147,7 @@ FloatingWindow { DankSlider { id: cornerRadiusSlider + wheelEnabled: false width: parent.width - 130 minimum: 0 maximum: 24 @@ -807,6 +1157,88 @@ FloatingWindow { } } + SectionHeader { + title: I18n.tr("Background Effect") + visible: isNiri + } + + StyledText { + width: parent.width + visible: isNiri + text: I18n.tr("Xray blurs only the wallpaper (efficient) and is the default when Blur is on. Set Xray to Off for regular full blur of everything beneath the window (more expensive).") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Flow { + width: parent.width + spacing: Theme.spacingS + visible: isNiri + + MatchCond { + id: blurCond + label: I18n.tr("Blur") + unsetLabel: I18n.tr("Inherit") + onTriStateChanged: { + if (triState === 2) + xrayCond.triState = 0; + } + } + MatchCond { + id: xrayCond + label: I18n.tr("X-Ray") + unsetLabel: I18n.tr("Inherit") + readOnly: blurCond.triState === 2 + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: isNiri + + CheckboxRow { + id: noiseEnabled + label: I18n.tr("Noise") + anchors.verticalCenter: parent.verticalCenter + } + + DankSlider { + id: noiseSlider + wheelEnabled: false + width: parent.width - 130 + minimum: 0 + maximum: 100 + value: 5 + enabled: noiseEnabled.checked + opacity: enabled ? 1 : 0.4 + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: isNiri + + CheckboxRow { + id: saturationEnabled + label: I18n.tr("Saturation") + anchors.verticalCenter: parent.verticalCenter + } + + DankSlider { + id: saturationSlider + wheelEnabled: false + width: parent.width - 130 + minimum: 0 + maximum: 200 + value: 100 + enabled: saturationEnabled.checked + opacity: enabled ? 1 : 0.4 + } + } + SectionHeader { title: I18n.tr("Size Constraints") } diff --git a/quickshell/Modules/Settings/WindowRulesTab.qml b/quickshell/Modules/Settings/WindowRulesTab.qml index 541b6768..2e199183 100644 --- a/quickshell/Modules/Settings/WindowRulesTab.qml +++ b/quickshell/Modules/Settings/WindowRulesTab.qml @@ -27,7 +27,107 @@ Item { property bool checkingInclude: false property bool fixingInclude: false property var windowRules: [] + property var externalRules: [] property var activeWindows: getActiveWindows() + property string expandedExternalId: "" + + readonly property var matchLabels: ({ + "appId": I18n.tr("App ID"), + "title": I18n.tr("Title"), + "isFloating": I18n.tr("Is Floating"), + "isActive": I18n.tr("Is Active"), + "isFocused": I18n.tr("Is Focused"), + "isActiveInColumn": I18n.tr("Active In Column"), + "isWindowCastTarget": I18n.tr("Cast Target"), + "isUrgent": I18n.tr("Is Urgent"), + "atStartup": I18n.tr("At Startup"), + "xwayland": I18n.tr("XWayland"), + "fullscreen": I18n.tr("Fullscreen"), + "pinned": I18n.tr("Pinned"), + "initialised": I18n.tr("Initialised") + }) + + function matchesOf(rule) { + const m = rule.matches; + if (m && m.length > 0) + return m; + return [rule.matchCriteria || {}]; + } + + function formatCriteria(obj, labels) { + let out = []; + const keys = Object.keys(obj || {}); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + const v = obj[k]; + if (v === undefined || v === null || v === "") + continue; + const label = labels[k] || k; + if (typeof v === "boolean") + out.push(label + ": " + (v ? I18n.tr("yes") : I18n.tr("no"))); + else + out.push(label + ": " + v); + } + return out; + } + + function matchSummary(rule) { + const matches = matchesOf(rule); + const first = matches[0] || {}; + const label = first.appId || first.title || I18n.tr("Any window"); + if (matches.length > 1) + return I18n.tr("%1 (+%2 more)").arg(label).arg(matches.length - 1); + return label; + } + + readonly property var actionLabels: ({ + "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"), + "drawBorderWithBackground": I18n.tr("Border w/ Bg"), + "backgroundBlur": I18n.tr("Blur"), + "backgroundXray": I18n.tr("X-Ray"), + "backgroundNoise": I18n.tr("Noise"), + "backgroundSaturation": I18n.tr("Saturation"), + "borderColor": I18n.tr("Border Color"), + "focusRingColor": I18n.tr("Focus Ring Color"), + "focusRingOff": I18n.tr("Focus Ring Off"), + "borderOff": I18n.tr("Border Off"), + "forcergbx": I18n.tr("Force RGBX"), + "idleinhibit": I18n.tr("Idle Inhibit") + }) signal rulesChanged @@ -72,18 +172,21 @@ Item { const compositor = CompositorService.compositor; if (compositor !== "niri" && compositor !== "hyprland") { windowRules = []; + externalRules = []; return; } Proc.runCommand("load-windowrules", ["dms", "config", "windowrules", "list", compositor], (output, exitCode) => { if (exitCode !== 0) { windowRules = []; + externalRules = []; return; } try { const result = JSON.parse(output.trim()); const allRules = result.rules || []; windowRules = allRules.filter(r => (r.source || "").includes("dms/windowrules")); + externalRules = allRules.filter(r => !(r.source || "").includes("dms/windowrules")); if (result.dmsStatus) { windowRulesIncludeStatus = { "exists": result.dmsStatus.exists, @@ -94,6 +197,7 @@ Item { } } catch (e) { windowRules = []; + externalRules = []; } }); } @@ -232,6 +336,20 @@ Item { } } + function copyRuleToDms(rule) { + if (readOnly) { + showHyprlandReadOnlyWarning(); + return; + } + if (!PopoutService.windowRuleModalLoader) + return; + PopoutService.windowRuleModalLoader.active = true; + if (PopoutService.windowRuleModalLoader.item) { + PopoutService.windowRuleModalLoader.item.onRuleSubmitted.connect(loadWindowRules); + PopoutService.windowRuleModalLoader.item.showCopy(rule); + } + } + function showHyprlandReadOnlyWarning() { ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing window rules in Settings."), "dms setup", "hyprland-migration"); } @@ -311,6 +429,8 @@ Item { iconColor: Theme.primary enabled: !root.readOnly opacity: enabled ? 1 : 0.5 + tooltipText: I18n.tr("Add Window Rule") + tooltipSide: "left" onClicked: root.openRuleModal() } } @@ -575,47 +695,11 @@ Item { 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") - }; + const labels = root.actionLabels; 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 val ? (labels[k] || k) : (labels[k] || k) + ": " + I18n.tr("off"); return (labels[k] || k) + ": " + val; }); } @@ -651,26 +735,26 @@ Item { iconColor: Theme.surfaceVariantText enabled: !root.readOnly opacity: enabled ? 1 : 0.5 + tooltipText: I18n.tr("Edit Rule") + tooltipSide: "top" onClicked: root.editRule(ruleDelegateItem.liveRuleData) } DankActionButton { id: deleteBtn + property bool hovered: false buttonSize: 28 iconName: "delete" iconSize: 16 backgroundColor: "transparent" - iconColor: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText + iconColor: hovered ? Theme.error : Theme.surfaceVariantText enabled: !root.readOnly opacity: enabled ? 1 : 0.5 - - MouseArea { - id: deleteArea - anchors.fill: parent - hoverEnabled: !root.readOnly - cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor - onClicked: root.removeRule(ruleDelegateItem.ruleIdRef) - } + tooltipText: I18n.tr("Delete Rule") + tooltipSide: "top" + onEntered: hovered = true + onExited: hovered = false + onClicked: root.removeRule(ruleDelegateItem.ruleIdRef) } } } @@ -729,6 +813,283 @@ Item { } } } + + StyledRect { + width: Math.min(650, parent.width - Theme.spacingL * 2) + height: externalSection.implicitHeight + Theme.spacingL * 2 + anchors.horizontalCenter: parent.horizontalCenter + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + visible: root.externalRules && root.externalRules.length > 0 + + Column { + id: externalSection + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + RowLayout { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "description" + size: Theme.iconSize + color: Theme.primary + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("User Window Rules (%1)").arg(root.externalRules?.length ?? 0) + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + Layout.fillWidth: true + } + + StyledText { + text: I18n.tr("Rules found in your compositor config. These are read-only here, use Convert to DMS to make an editable copy.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingXS + + Repeater { + model: ScriptModel { + objectProp: "id" + values: root.externalRules || [] + } + + delegate: Rectangle { + id: externalCard + required property var modelData + + readonly property string displayName: { + const name = externalCard.modelData.name || ""; + if (name) + return name; + return root.matchSummary(externalCard.modelData); + } + readonly property string sourceFile: (externalCard.modelData.source || "").split("/").pop() + readonly property bool expanded: root.expandedExternalId === externalCard.modelData.id + + width: parent.width + height: externalContent.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainer, 0.4) + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.expandedExternalId = externalCard.expanded ? "" : externalCard.modelData.id + } + + Column { + id: externalContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + RowLayout { + width: parent.width + spacing: Theme.spacingM + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingS + + StyledText { + text: externalCard.displayName + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + Layout.fillWidth: true + } + + Rectangle { + visible: externalCard.sourceFile.length > 0 + width: sourceText.implicitWidth + Theme.spacingS * 2 + height: 20 + radius: 10 + color: Theme.withAlpha(Theme.surfaceVariantText, 0.15) + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: sourceText + anchors.centerIn: parent + text: externalCard.sourceFile + font.pixelSize: Theme.fontSizeSmall - 2 + color: Theme.surfaceVariantText + } + } + } + + StyledText { + text: { + const m = externalCard.modelData.matchCriteria || {}; + let parts = []; + if (m.appId) + parts.push(m.appId); + if (m.title) + parts.push("title: " + m.title); + const base = parts.length > 0 ? parts.join(" · ") : I18n.tr("No match criteria"); + const count = root.matchesOf(externalCard.modelData).length; + return count > 1 ? I18n.tr("%1 (+%2 more)").arg(base).arg(count - 1) : base; + } + 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 = externalCard.modelData.actions || {}; + return Object.keys(a).some(k => a[k] !== undefined && a[k] !== null && a[k] !== ""); + } + + Repeater { + model: { + const a = externalCard.modelData.actions || {}; + const labels = root.actionLabels; + 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 val ? (labels[k] || k) : (labels[k] || k) + ": " + I18n.tr("off"); + return (labels[k] || k) + ": " + val; + }); + } + + delegate: Rectangle { + required property string modelData + width: extChipText.implicitWidth + Theme.spacingS * 2 + height: 20 + radius: 10 + color: Theme.withAlpha(Theme.primary, 0.15) + + StyledText { + id: extChipText + anchors.centerIn: parent + text: modelData + font.pixelSize: Theme.fontSizeSmall - 2 + color: Theme.primary + } + } + } + } + } + + DankIcon { + name: externalCard.expanded ? "expand_less" : "expand_more" + size: 20 + color: Theme.surfaceVariantText + Layout.alignment: Qt.AlignVCenter + } + + DankActionButton { + buttonSize: 28 + iconName: "content_copy" + iconSize: 16 + backgroundColor: "transparent" + iconColor: Theme.surfaceVariantText + enabled: !root.readOnly + opacity: enabled ? 1 : 0.5 + Layout.alignment: Qt.AlignVCenter + tooltipText: I18n.tr("Convert to DMS") + tooltipSide: "left" + onClicked: root.copyRuleToDms(externalCard.modelData) + } + } + + Column { + width: parent.width + spacing: Theme.spacingXS + visible: externalCard.expanded + + Rectangle { + width: parent.width + height: 1 + color: Theme.withAlpha(Theme.outline, 0.5) + } + + StyledText { + text: I18n.tr("Match (%1)").arg(root.matchesOf(externalCard.modelData).length) + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + } + + Repeater { + model: root.matchesOf(externalCard.modelData) + + delegate: StyledText { + required property var modelData + width: parent.width + text: { + const c = root.formatCriteria(modelData, root.matchLabels); + return "• " + (c.length > 0 ? c.join(" · ") : I18n.tr("Any window")); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + } + + StyledText { + text: I18n.tr("Actions") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + topPadding: Theme.spacingXS + } + + StyledText { + width: parent.width + text: { + const a = root.formatCriteria(externalCard.modelData.actions, root.actionLabels); + return a.length > 0 ? a.join(" · ") : I18n.tr("None"); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + StyledText { + width: parent.width + text: I18n.tr("Source: %1").arg(externalCard.modelData.source || "") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + elide: Text.ElideMiddle + topPadding: Theme.spacingXS + } + } + } + } + } + } + } + } } } }