Files
DankMaterialShell/quickshell/Modules/Settings/WindowRulesTab.qml
T
Marlon Kranz 72c23161e7 fix(hyprland): write window rules Hyprland 0.56 accepts, keep DMS windows opaque (#3655)
* fix(hyprland): write window rules Hyprland 0.56 accepts, keep DMS windows opaque

The Lua window-rule writer emitted match keys and effects Hyprland 0.56
does not know: `floating`/`pinned`/`initialised` as match keys and
`noborder`/`norounding` as effects. It now writes `float`/`pin` and
`border_size = 0`/`rounding = 0`; the parser still reads the old
spellings, so existing files load and are rewritten on the next save.
"No Rounding" now suppresses the corner radius so a rule has one
`rounding` key.

The rule editor no longer offers fields the Hyprland writer drops
(Window Opening output/workspace, Size Constraints, the Initialised
match), and Save is disabled while only one half of a move or size
pair is filled. Every window-rule write now reloads the compositor
through one helper, which also covers Hyprland.

DMS windows draw their own translucent background, so any compositor
opacity rule or decoration opacity multiplied on top of it. DMS now
keeps a hidden rule for its own app id: `opaque = true` on Hyprland
(applied after rule and decoration opacity, independent of order, and
leaving the window's own alpha alone) and `opacity 1.0` on niri, which
the store keeps last. It is re-added on start when missing and hidden
from the rules list and the focused-window menu.

Compositor layout gains a Hyprland "Window opacity" slider writing
decoration.active_opacity/inactive_opacity; 100 leaves the user's own
config in charge.

HyprlandService.reloadConfig queues callbacks: Proc keeps one callback
per id, so a reload requested while the layout writer's reload was
pending dropped its callback and left layout generation stuck.

* fix(windowrules): cap corner radius at 20

Hyprland 0.56's Lua window_rule rejects rounding above 20 ("value 24
is more than the maximum of 20") and the rule loses its rounding. The
Hyprland writer now clamps to 20, and the editor slider stops at 20
for every compositor.
2026-10-07 14:09:49 -04:00

672 lines
26 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
import "../../Common/WindowRuleSize.js" as WindowRuleSize
import qs.Services
import qs.DCommon.Widgets
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property var parentModal: null
property bool pageActive: true
property bool componentReady: false
readonly property bool readOnly: windowRulesInclude.readOnly
property bool savingRuleOrder: false
property var windowRules: []
property var externalRules: []
property var activeWindows: getActiveWindows()
property string expandedExternalId: ""
readonly property string dmsRulesFileName: "dms/" + (ConfigIncludeResolve.includeSpec("windowrules", CompositorService.compositor)?.fragmentNames[0] ?? "windowrules.lua")
Component.onDestruction: SettingsSearchService.unregisterCard("windowRules")
ConfigInclude {
id: windowRulesInclude
includeKind: "windowrules"
autoCheck: false
onFixed: {
if (CompositorService.isMango)
MangoService.reloadConfig();
root.loadWindowRules();
}
}
onPageActiveChanged: {
if (componentReady && pageActive)
loadWindowRules();
}
readonly property var matchLabels: ({
"appId": I18n.tr("App ID"),
"title": I18n.tr("Title", "noun, window title match field, also name field label"),
"isFloating": I18n.tr("Floating"),
"isActive": I18n.tr("Active", "active state label"),
"isFocused": I18n.tr("Focused"),
"isActiveInColumn": I18n.tr("Active in column"),
"isWindowCastTarget": I18n.tr("Cast target"),
"isUrgent": I18n.tr("Urgent"),
"atStartup": I18n.tr("At startup"),
"xwayland": "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 actionChips(actions) {
const a = actions || {};
return Object.keys(a).filter(k => a[k] !== undefined && a[k] !== null && a[k] !== "").map(k => {
const label = root.actionLabels[k] || k;
if (typeof a[k] === "boolean")
return a[k] ? label : label + ": " + I18n.tr("Off");
if (k === "defaultColumnWidth" || k === "defaultWindowHeight")
return label + ": " + WindowRuleSize.label(a[k]);
return label + ": " + a[k];
});
}
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)", "window rule summary, %1 is first match, %2 is remaining count").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", "window rule action, open maximized to screen 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": "VRR",
"blockOutFrom": I18n.tr("Block out"),
"defaultColumnDisplay": I18n.tr("Display"),
"scrollFactor": I18n.tr("Scroll"),
"cornerRadius": I18n.tr("Radius"),
"clipToGeometry": I18n.tr("Clip", "verb, short window rule property label for clip to geometry"),
"tiledState": I18n.tr("Tiled", "adjective, short window rule property label for tiled state"),
"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", "verb, keep an item pinned in place"),
"opaque": I18n.tr("Opaque"),
"sizeWidth": I18n.tr("W"),
"sizeHeight": I18n.tr("H"),
"moveX": "X",
"moveY": "Y",
"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"),
"defaultFloatingX": I18n.tr("Float X"),
"defaultFloatingY": I18n.tr("Float Y"),
"defaultFloatingRelativeTo": I18n.tr("Float anchor"),
"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 inhibitor", "feature that keeps the session from going idle")
})
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 loadWindowRules() {
const compositor = CompositorService.compositor;
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango") {
windowRulesInclude.checking = false;
windowRules = [];
externalRules = [];
return;
}
windowRulesInclude.checking = true;
Proc.runCommand("load-windowrules", [Proc.dmsBin, "config", "windowrules", "list", compositor], (output, exitCode) => {
windowRulesInclude.checking = false;
if (exitCode !== 0) {
windowRules = [];
externalRules = [];
return;
}
try {
const result = JSON.parse(output.trim());
const allRules = result.rules || [];
CompositorService.syncDmsWindowFloatingRule(allRules);
windowRules = allRules.filter(r => (r.source || "").includes("dms/windowrules") && r.id !== CompositorService.dmsOpaqueRuleId);
externalRules = allRules.filter(r => !(r.source || "").includes("dms/windowrules"));
windowRulesInclude.applyStatus(result.dmsStatus);
} catch (e) {
windowRules = [];
externalRules = [];
}
});
}
function removeRule(ruleId) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
const compositor = CompositorService.compositor;
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango")
return;
Proc.runCommand("remove-windowrule", [Proc.dmsBin, "config", "windowrules", "remove", compositor, ruleId], (output, exitCode) => {
if (exitCode === 0) {
CompositorService.reloadAfterWindowRuleWrite();
loadWindowRules();
rulesChanged();
}
});
}
function reorderRules(indices) {
if (savingRuleOrder)
return;
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
const compositor = CompositorService.compositor;
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango")
return;
const previous = windowRules;
const reordered = indices.map(index => previous[index]);
const ids = reordered.map(rule => rule.id);
savingRuleOrder = true;
windowRules = reordered;
Proc.runCommand("reorder-windowrules", [Proc.dmsBin, "config", "windowrules", "reorder", compositor, JSON.stringify(ids)], (output, exitCode) => {
savingRuleOrder = false;
if (exitCode !== 0) {
windowRules = previous;
return;
}
CompositorService.reloadAfterWindowRuleWrite();
loadWindowRules();
rulesChanged();
});
}
property bool editorOpen: false
property bool editorMounted: false
property var editorRequest: null
function openRuleModal(window) {
openEditor("new", window || null);
}
function editRule(rule) {
openEditor("edit", rule);
}
function copyRuleToDms(rule) {
openEditor("copy", rule);
}
function openEditor(mode, payload) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (editorOpen)
return;
editorRequest = {
mode,
payload
};
editorOpen = true;
editorMounted = true;
if (editorLoader.item)
presentEditor();
}
function presentEditor() {
const request = editorRequest;
if (!request)
return;
switch (request.mode) {
case "edit":
editorLoader.item.showEdit(request.payload);
return;
case "copy":
editorLoader.item.showCopy(request.payload);
return;
default:
editorLoader.item.show(request.payload);
}
}
function closeEditor() {
if (!editorOpen)
return;
editorOpen = false;
editorRequest = null;
if (editorLoader.item)
editorLoader.item.opened = false;
}
function showHyprlandReadOnlyWarning() {
ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before changing these settings."), "dms setup", "hyprland-migration");
}
Component.onCompleted: {
componentReady = true;
Qt.callLater(() => {
SettingsSearchService.registerCard("windowRules", headerSection, flickable);
if (CompositorService.supportsWindowRules)
loadWindowRules();
});
}
SettingsPage {
id: flickable
SettingsCard {
id: headerSection
width: parent.width
iconName: "select_window"
SettingsRow {
subtitle: I18n.tr("Define rules for window behavior. Saves to %1", "window rules settings description, %1 is a config file name").arg(root.dmsRulesFileName)
}
SettingsRow {
id: createRuleRow
visible: root.activeWindows.length > 0
title: I18n.tr("Create rule for:")
DDropdown {
id: windowSelector
downKeyOpens: false
backgroundColor: SettingsMetrics.controlSurface
anchors.verticalCenter: parent.verticalCenter
dropdownWidth: Math.min(400, createRuleRow.width - SettingsMetrics.rowPaddingH * 2)
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 = "";
}
}
}
}
IncludeSetupBanner {
include: windowRulesInclude
visibleCondition: windowRulesInclude.compositorSupported
}
SettingsCard {
width: parent.width
iconName: "list"
title: I18n.tr("Rules (%1)", "window rules settings card title, %1 is a count").arg(root.windowRules?.length ?? 0)
SettingsRow {
visible: !root.windowRules || root.windowRules.length === 0
body: Column {
width: parent.width
spacing: Theme.spacingXS
DIcon {
name: "select_window"
size: Theme.iconSizeLarge
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("No window rules configured")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
SettingsReorderList {
id: rulesListColumn
model: root.windowRules || []
visible: count > 0
enabled: !root.savingRuleOrder
onReordered: indices => root.reorderRules(indices)
delegate: SettingsReorderRow {
id: ruleRow
required property var modelData
reorderList: rulesListColumn
reorderEnabled: !root.readOnly
readonly property string ruleIdRef: modelData.id
readonly property var liveRuleData: (root.windowRules || []).find(rule => rule.id === ruleIdRef) ?? modelData
title: liveRuleData.name || liveRuleData.matchCriteria?.appId || liveRuleData.matchCriteria?.title || I18n.tr("Unnamed rule")
subtitle: {
const criteria = liveRuleData.matchCriteria || {};
const parts = [];
if (criteria.appId)
parts.push(criteria.appId);
if (criteria.title)
parts.push("title: " + criteria.title);
return parts.length > 0 ? parts.join(" · ") : I18n.tr("No match criteria");
}
DActionButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "edit"
enabled: !root.readOnly
Accessible.name: I18n.tr("Edit rule")
onClicked: root.editRule(ruleRow.liveRuleData)
}
DActionButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "delete"
iconColor: Theme.error
enabled: !root.readOnly
Accessible.name: I18n.tr("Delete rule")
onClicked: root.removeRule(ruleRow.ruleIdRef)
}
body: Flow {
width: parent.width
spacing: Theme.spacingXS
visible: actionRepeater.count > 0
Repeater {
id: actionRepeater
model: root.actionChips(ruleRow.liveRuleData.actions)
delegate: DBadge {
required property string modelData
maximumWidth: parent?.width ?? 0
text: modelData
color: Theme.primaryContainer
textColor: Theme.onPrimaryContainer
tooltipText: truncated ? modelData : null
}
}
}
}
}
}
SettingsCard {
width: parent.width
iconName: "description"
title: I18n.tr("User window rules (%1)", "card title for read-only compositor config rules, %1 is a count").arg(root.externalRules?.length ?? 0)
visible: root.externalRules && root.externalRules.length > 0
SettingsRow {
body: StyledText {
width: parent.width
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
}
}
Repeater {
model: ScriptModel {
objectProp: "id"
values: root.externalRules || []
}
delegate: SettingsRow {
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
readonly property bool hasActions: {
const a = externalCard.modelData.actions || {};
return Object.keys(a).some(k => a[k] !== undefined && a[k] !== null && a[k] !== "");
}
title: externalCard.displayName
subtitle: {
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;
}
clickable: true
onClicked: root.expandedExternalId = externalCard.expanded ? "" : externalCard.modelData.id
DBadge {
visible: externalCard.sourceFile.length > 0
anchors.verticalCenter: parent.verticalCenter
text: externalCard.sourceFile
color: SettingsMetrics.controlColor
textColor: Theme.surfaceVariantText
}
DIcon {
name: externalCard.expanded ? "expand_less" : "expand_more"
size: Theme.iconSize
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
DActionButton {
iconName: "content_copy"
iconColor: Theme.surfaceVariantText
enabled: !root.readOnly
anchors.verticalCenter: parent.verticalCenter
tooltipText: I18n.tr("Convert to DMS")
tooltipSide: "left"
onClicked: root.copyRuleToDms(externalCard.modelData)
}
body: Column {
width: parent.width
spacing: Theme.spacingS
visible: externalCard.hasActions || externalCard.expanded
Flow {
width: parent.width
spacing: Theme.spacingXS
visible: externalCard.hasActions
Repeater {
model: root.actionChips(externalCard.modelData.actions)
delegate: DBadge {
required property string modelData
maximumWidth: parent?.width ?? 0
text: modelData
color: Theme.primaryContainer
textColor: Theme.onPrimaryContainer
tooltipText: truncated ? modelData : null
}
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
visible: externalCard.expanded
SettingsDivider {}
StyledText {
text: I18n.tr("Match (%1)", "noun, window rule match criteria heading, %1 is a count").arg(root.matchesOf(externalCard.modelData).length)
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightMedium
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: Theme.fontWeightMedium
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", "window rule detail, %1 is the config file path").arg(externalCard.modelData.source || "")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
elide: Text.ElideMiddle
topPadding: Theme.spacingXS
}
}
}
}
}
}
SettingsFabBar {
shown: !root.readOnly
DFab {
text: I18n.tr("Add window rule")
iconName: "add"
onClicked: root.openRuleModal()
}
}
}
Loader {
id: editorLoader
parent: root.parentModal?.modalFocusScope ?? root
anchors.fill: parent
z: 100
active: root.editorMounted
onLoaded: root.presentEditor()
sourceComponent: WindowRuleEditorDialog {
supportingText: I18n.tr("Changes save to %1", "hint under the keybind and window rule editors, %1 is the config file the changes are written to").arg(root.dmsRulesFileName)
onRejected: root.closeEditor()
onRuleSubmitted: {
root.loadWindowRules();
root.closeEditor();
}
onActiveChanged: {
if (!active && !root.editorOpen)
root.editorMounted = false;
}
}
}
}