Files
DankMaterialShell/quickshell/Modules/Settings/WindowRuleEditorDialog.qml
T
purian23 2747a75198 hyprland: bring up features parity overhaul w/0.56+ and upcoming stable releases
- new blur variants, glow, wobble, motion blur and groupbar support
- new inputs support for mouse, touchpad and keyboard settings
- greeter, cheatsheet and scroll capture dispatch in Lua
- scrolling workspace screenshot focus fix
- fix scrolling screenshots from being cut-off on fractional-scale displays
- workspaces are keyed by address on the beta branch and upcoming stable release where named workspaces have no id
- bind flags, per-device binds, new window-rule keys and effects
- outputs default to preferred/auto/auto and gate HDR/VRR on hardwareDetails
- new dms setup/installer systemd session support on hyprland-session.target
2026-10-09 05:14:55 -04:00

1545 lines
57 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import "../../Common/WindowRuleSize.js" as WindowRuleSize
import "../../Common/WindowRuleMerge.js" as WindowRuleMerge
import qs.Services
import qs.DCommon.Widgets
import qs.Widgets
import qs.Modules.Settings.Widgets
DDialog {
id: root
property var editingRule: null
property var targetWindow: null
property bool submitting: false
readonly property bool isEditMode: editingRule !== null
readonly property bool isNiri: CompositorService.isNiri
readonly property bool isHyprland: CompositorService.isHyprland
readonly property bool isMango: CompositorService.isMango
readonly property bool fieldsEnabled: !submitting
readonly property bool halfPair: isHyprland && (!!moveXInput.text.trim() !== !!moveYInput.text.trim() || !!sizeWInput.text.trim() !== !!sizeHInput.text.trim())
property int floatingTri: 0
property var openingFlags: []
property var dynamicFlags: []
property var hyprFlags: []
property var mangoFlags: []
property bool opacityOn: false
property int opacityValue: 100
property bool scrollFactorOn: false
property int scrollFactorValue: 100
property bool cornerRadiusOn: false
property int cornerRadiusValue: 12
property bool noiseOn: false
property int noiseValue: 5
property bool saturationOn: false
property int saturationValue: 100
property string blockOutValue: ""
property string columnDisplayValue: ""
property string floatingRelativeValue: "top-left"
property string columnWidthUnit: WindowRuleSize.PIXELS
property string windowHeightUnit: WindowRuleSize.PIXELS
property var storedMatch: ({})
property var storedActions: ({})
property var hyprEffectFlags: []
property int focusOnActivateTri: 0
property bool scrollingWidthOn: false
property int scrollingWidthValue: 50
property string tonemapValue: ""
property string contentValue: ""
property int fullscreenInternalValue: -1
property int fullscreenClientValue: -1
readonly property var triLabels: [I18n.tr("Default"), I18n.tr("On"), I18n.tr("Off")]
readonly property var tonemapOptions: root.withCurrent(["", "off", "on", "clamp", "limited"], root.tonemapValue)
readonly property var contentOptions: root.withCurrent(["", "none", "photo", "video", "game"], root.contentValue)
// Index is the Hyprland state + 1; 0 leaves the match unset.
readonly property var fullscreenStateLabels: [I18n.tr("Default"), I18n.tr("None"), I18n.tr("Maximized", "adjective, Hyprland window fullscreen state 1"), I18n.tr("Fullscreen"), I18n.tr("Maximized and fullscreen", "Hyprland window fullscreen state 3")]
readonly property var hyprMatchKeys: ["initialClass", "initialTitle", "tag", "workspace", "content", "xdgTag", "fullscreenStateInternal", "fullscreenStateClient"]
readonly property var hyprFocusOptions: [
{
"label": I18n.tr("No initial focus"),
"value": "noInitialFocus",
"tooltip": I18n.tr("Opens without taking keyboard focus", "window rule option tooltip")
},
{
"label": I18n.tr("Stay focused"),
"value": "stayFocused",
"tooltip": I18n.tr("Keeps focus while the window is open", "window rule option tooltip")
}
]
readonly property var hyprPointerOptions: [
{
"label": I18n.tr("Confine pointer"),
"value": "confinePointer",
"tooltip": I18n.tr("Keeps the pointer inside the window", "window rule option tooltip")
},
{
"label": I18n.tr("No XDG drags"),
"value": "noXdgDrags",
"tooltip": I18n.tr("Ignores drag and drop started by the window", "window rule option tooltip")
}
]
readonly property var hyprRenderOptions: [
{
"label": I18n.tr("No glow"),
"value": "noGlow",
"needs": "decoration:glow:enabled"
},
{
"label": I18n.tr("No wobble"),
"value": "noWobble",
"needs": "decoration:wobble:enabled"
},
{
"label": I18n.tr("No auto HDR"),
"value": "noAutoHdr",
"tooltip": I18n.tr("Never switches the monitor to HDR for this window", "window rule option tooltip")
}
].filter(option => !option.needs || HyprlandService.hyprSupports(option.needs))
readonly property var hyprEffectOptions: root.hyprFocusOptions.concat(root.hyprPointerOptions, root.hyprRenderOptions)
readonly property var blockOutOptions: ["", "screencast", "screen-capture"]
readonly property var columnDisplayOptions: ["", "tabbed"]
readonly property var anchorOptions: ["top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right"]
readonly property var openingOptions: {
const options = [];
if (!isNiri)
options.push({
"label": I18n.tr("Float"),
"value": "float"
});
if (!isMango)
options.push({
"label": I18n.tr("Maximize", "verb, window rule action to open the window maximized"),
"value": "maximize"
});
options.push({
"label": I18n.tr("Fullscreen", "window rule action, open the window fullscreen"),
"value": "fullscreen"
});
if (isNiri) {
options.push({
"label": I18n.tr("Max edges", "window rule action, open maximized to screen edges"),
"value": "maxEdges"
});
options.push({
"label": I18n.tr("Focus", "verb, window rule action to focus the window when it opens"),
"value": "focus"
});
}
return options;
}
readonly property var dynamicOptions: [
{
"label": I18n.tr("VRR On-Demand"),
"value": "vrr"
},
{
"label": I18n.tr("Clip to Geometry"),
"value": "clip"
},
{
"label": I18n.tr("Tiled State"),
"value": "tiled"
},
{
"label": I18n.tr("Border with Background"),
"value": "borderBg"
}
]
readonly property var hyprOptions: [
{
"label": I18n.tr("Tile"),
"value": "tile"
},
{
"label": I18n.tr("No focus"),
"value": "nofocus"
},
{
"label": I18n.tr("No border"),
"value": "noborder"
},
{
"label": I18n.tr("No shadow"),
"value": "noshadow"
},
{
"label": I18n.tr("No dim"),
"value": "nodim"
},
{
"label": I18n.tr("No blur"),
"value": "noblur"
},
{
"label": I18n.tr("No anim"),
"value": "noanim"
},
{
"label": I18n.tr("No Rounding"),
"value": "norounding"
},
{
"label": I18n.tr("Pin", "verb, keep an item pinned in place"),
"value": "pin"
},
{
"label": I18n.tr("Opaque", "adjective, window rule checkbox forcing an opaque window"),
"value": "opaque"
}
]
readonly property var mangoOptions: [
{
"label": I18n.tr("No blur"),
"value": "noblur"
},
{
"label": I18n.tr("No border"),
"value": "noborder"
},
{
"label": I18n.tr("No shadow"),
"value": "noshadow"
},
{
"label": I18n.tr("No Rounding"),
"value": "norounding"
},
{
"label": I18n.tr("No anim"),
"value": "noanim"
}
]
signal ruleSubmitted
embedded: nativeWindow
opened: nativeWindow
maximumWidth: SettingsMetrics.formDialogWidth
surfaceColor: Theme.hostSurface
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("New Window Rule")
closeEnabled: !submitting
acceptEnabled: !submitting && !halfPair
onAccepted: submit()
ListModel {
id: extraMatchModel
}
function show(window) {
editingRule = null;
targetWindow = window || null;
resetForm();
if (targetWindow?.appId) {
nameInput.text = targetWindow.appId;
appIdInput.text = isMango ? targetWindow.appId : "^" + targetWindow.appId + "$";
}
present();
}
function showEdit(rule) {
if (!rule) {
show();
return;
}
editingRule = rule;
targetWindow = null;
resetForm();
populateForm(rule);
present();
}
function showCopy(rule) {
if (!rule) {
show();
return;
}
editingRule = null;
targetWindow = null;
resetForm();
populateForm(rule);
present();
}
function present() {
opened = true;
forceActiveFocus();
nameInput.forceActiveFocus();
}
function flagSet(flags, value, checked) {
const next = flags.filter(flag => flag !== value);
if (checked)
next.push(value);
return next;
}
function triFromBool(value) {
if (value === true)
return 1;
if (value === false)
return 2;
return 0;
}
function applyCond(target, key, triState) {
if (triState === 1)
target[key] = true;
else if (triState === 2)
target[key] = false;
}
function blockOutLabel(value) {
return value === "" ? I18n.tr("None") : value;
}
function columnDisplayLabel(value) {
return value === "" ? I18n.tr("Normal") : value;
}
function defaultLabel(value) {
return value === "" ? I18n.tr("Default") : value;
}
// A value written by hand that the list does not know must still show and save.
function withCurrent(values, current) {
return values.includes(current) ? values : values.concat([current]);
}
function resetForm() {
nameInput.text = "";
appIdInput.text = "";
titleInput.text = "";
extraMatchModel.clear();
for (const cond of matchConditions)
cond.triState = 0;
floatingTri = 0;
openingFlags = [];
dynamicFlags = [];
hyprFlags = [];
mangoFlags = [];
opacityOn = false;
opacityValue = 100;
scrollFactorOn = false;
scrollFactorValue = 100;
cornerRadiusOn = false;
cornerRadiusValue = 12;
noiseOn = false;
noiseValue = 5;
saturationOn = false;
saturationValue = 100;
blockOutValue = "";
columnDisplayValue = "";
floatingRelativeValue = "top-left";
columnWidthUnit = WindowRuleSize.PIXELS;
windowHeightUnit = WindowRuleSize.PIXELS;
blurCond.triState = 0;
xrayCond.triState = 0;
storedMatch = {};
storedActions = {};
hyprEffectFlags = [];
focusOnActivateTri = 0;
scrollingWidthOn = false;
scrollingWidthValue = 50;
tonemapValue = "";
contentValue = "";
fullscreenInternalValue = -1;
fullscreenClientValue = -1;
hyprMatchCard.expanded = false;
hyprEffectsCard.expanded = false;
for (const field of [outputInput, workspaceInput, columnWidthInput, windowHeightInput, floatingXInput, floatingYInput, minWidthInput, maxWidthInput, minHeightInput, maxHeightInput, moveXInput, moveYInput, sizeWInput, sizeHInput, monitorInput, hyprWorkspaceInput, mangoTagsInput, mangoMonitorInput, mangoSizeInput, initialClassInput, initialTitleInput, matchTagInput, xdgTagInput, matchWorkspaceInput, borderColorInput, suppressEventInput])
field.text = "";
}
function populateForm(rule) {
nameInput.text = rule.name || "";
const matchList = (rule.matches && rule.matches.length > 0) ? rule.matches : [rule.matchCriteria || {}];
const match = matchList[0] || {};
storedMatch = match;
appIdInput.text = match.appId || "";
titleInput.text = match.title || "";
for (let i = 1; i < matchList.length; i++)
extraMatchModel.append({
"rowAppId": matchList[i].appId || "",
"rowTitle": matchList[i].title || "",
"rowStored": JSON.stringify(matchList[i])
});
for (const cond of matchConditions)
cond.triState = triFromBool(match[cond.key]);
initialClassInput.text = match.initialClass || "";
initialTitleInput.text = match.initialTitle || "";
matchTagInput.text = match.tag || "";
xdgTagInput.text = match.xdgTag || "";
matchWorkspaceInput.text = match.workspace || "";
contentValue = match.content || "";
fullscreenInternalValue = match.fullscreenStateInternal ?? -1;
fullscreenClientValue = match.fullscreenStateClient ?? -1;
hyprMatchCard.expanded = hyprMatchKeys.some(key => match[key] !== undefined && match[key] !== "");
const actions = rule.actions || {};
storedActions = actions;
const flags = [];
if (!isNiri && actions.openFloating)
flags.push("float");
if (actions.openMaximized)
flags.push("maximize");
if (actions.openFullscreen)
flags.push("fullscreen");
if (actions.openMaximizedToEdges)
flags.push("maxEdges");
if (actions.openFocused)
flags.push("focus");
openingFlags = flags;
floatingTri = triFromBool(actions.openFloating);
const dynamic = [];
if (actions.variableRefreshRate)
dynamic.push("vrr");
if (actions.clipToGeometry)
dynamic.push("clip");
if (actions.tiledState)
dynamic.push("tiled");
if (actions.drawBorderWithBackground)
dynamic.push("borderBg");
dynamicFlags = dynamic;
opacityOn = actions.opacity !== undefined && actions.opacity !== null;
opacityValue = opacityOn ? Math.round(actions.opacity * 100) : 100;
scrollFactorOn = actions.scrollFactor !== undefined && actions.scrollFactor !== null;
scrollFactorValue = scrollFactorOn ? Math.round(actions.scrollFactor * 100) : 100;
cornerRadiusOn = actions.cornerRadius !== undefined && actions.cornerRadius !== null;
cornerRadiusValue = cornerRadiusOn ? actions.cornerRadius : 12;
noiseOn = actions.backgroundNoise !== undefined && actions.backgroundNoise !== null;
noiseValue = noiseOn ? Math.round(actions.backgroundNoise * 100) : 5;
saturationOn = actions.backgroundSaturation !== undefined && actions.backgroundSaturation !== null;
saturationValue = saturationOn ? Math.round(actions.backgroundSaturation * 100) : 100;
blockOutValue = actions.blockOutFrom || "";
columnDisplayValue = actions.defaultColumnDisplay || "";
blurCond.triState = triFromBool(actions.backgroundBlur);
xrayCond.triState = triFromBool(actions.backgroundXray);
outputInput.text = actions.openOnOutput || "";
workspaceInput.text = actions.openOnWorkspace || "";
const columnWidth = WindowRuleSize.parse(actions.defaultColumnWidth);
const windowHeight = WindowRuleSize.parse(actions.defaultWindowHeight);
columnWidthUnit = columnWidth.unit;
windowHeightUnit = windowHeight.unit;
columnWidthInput.text = columnWidth.amount;
windowHeightInput.text = windowHeight.amount;
floatingXInput.text = (actions.defaultFloatingX !== undefined && actions.defaultFloatingX !== null) ? String(actions.defaultFloatingX) : "";
floatingYInput.text = (actions.defaultFloatingY !== undefined && actions.defaultFloatingY !== null) ? String(actions.defaultFloatingY) : "";
floatingRelativeValue = actions.defaultFloatingRelativeTo || "top-left";
minWidthInput.text = actions.minWidth !== undefined ? String(actions.minWidth) : "";
maxWidthInput.text = actions.maxWidth !== undefined ? String(actions.maxWidth) : "";
minHeightInput.text = actions.minHeight !== undefined ? String(actions.minHeight) : "";
maxHeightInput.text = actions.maxHeight !== undefined ? String(actions.maxHeight) : "";
hyprFlags = hyprOptions.map(option => option.value).filter(value => actions[value]);
moveXInput.text = actions.moveX || "";
moveYInput.text = actions.moveY || "";
sizeWInput.text = actions.sizeWidth || "";
sizeHInput.text = actions.sizeHeight || "";
monitorInput.text = actions.monitor || "";
hyprWorkspaceInput.text = actions.workspace || "";
hyprEffectFlags = hyprEffectOptions.map(option => option.value).filter(value => actions[value] === true);
focusOnActivateTri = triFromBool(actions.focusOnActivate);
scrollingWidthOn = actions.scrollingWidth !== undefined && actions.scrollingWidth !== null;
scrollingWidthValue = scrollingWidthOn ? Math.round(actions.scrollingWidth * 100) : 50;
tonemapValue = actions.tonemap || "";
borderColorInput.text = actions.borderColor || "";
suppressEventInput.text = actions.suppressEvent || "";
hyprEffectsCard.expanded = hyprEffectFlags.length > 0 || focusOnActivateTri !== 0 || scrollingWidthOn || !!tonemapValue || !!borderColorInput.text || !!suppressEventInput.text;
mangoFlags = mangoOptions.map(option => option.value).filter(value => actions[value]);
mangoTagsInput.text = actions.workspace || "";
mangoMonitorInput.text = actions.monitor || "";
mangoSizeInput.text = (actions.sizeWidth && actions.sizeHeight) ? actions.sizeWidth + "x" + actions.sizeHeight : "";
}
function collectMatches() {
const conds = matchConditions.filter(cond => cond.visible);
const owned = ["appId", "title"].concat(conds.map(cond => cond.key), isHyprland ? hyprMatchKeys : []);
const matchCriteria = WindowRuleMerge.carry(storedMatch, owned);
if (appIdInput.text.trim())
matchCriteria.appId = appIdInput.text.trim();
if (titleInput.text.trim())
matchCriteria.title = titleInput.text.trim();
for (const cond of conds)
applyCond(matchCriteria, cond.key, cond.triState);
if (isHyprland) {
const texts = {
"initialClass": initialClassInput.text.trim(),
"initialTitle": initialTitleInput.text.trim(),
"tag": matchTagInput.text.trim(),
"xdgTag": xdgTagInput.text.trim(),
"workspace": matchWorkspaceInput.text.trim(),
"content": contentValue
};
for (const key in texts) {
if (texts[key])
matchCriteria[key] = texts[key];
}
if (fullscreenInternalValue >= 0)
matchCriteria.fullscreenStateInternal = fullscreenInternalValue;
if (fullscreenClientValue >= 0)
matchCriteria.fullscreenStateClient = fullscreenClientValue;
}
const matches = [];
if (Object.keys(matchCriteria).length > 0)
matches.push(matchCriteria);
if (!isNiri)
return {
matchCriteria,
matches
};
for (let i = 0; i < extraMatchModel.count; i++) {
const row = extraMatchModel.get(i);
const extra = WindowRuleMerge.carry(JSON.parse(row.rowStored || "{}"), ["appId", "title"]);
if ((row.rowAppId || "").trim())
extra.appId = row.rowAppId.trim();
if ((row.rowTitle || "").trim())
extra.title = row.rowTitle.trim();
if (Object.keys(extra).length > 0)
matches.push(extra);
}
return {
matchCriteria,
matches
};
}
function ownedActionKeys() {
const keys = ["opacity", "openFloating", "openMaximized", "openFullscreen", "cornerRadius"];
if (isNiri)
return keys.concat(["openMaximizedToEdges", "openFocused", "openOnOutput", "openOnWorkspace", "minWidth", "maxWidth", "minHeight", "maxHeight", "defaultColumnWidth", "defaultWindowHeight", "variableRefreshRate", "clipToGeometry", "tiledState", "drawBorderWithBackground", "blockOutFrom", "defaultColumnDisplay", "scrollFactor", "backgroundBlur", "backgroundXray", "backgroundNoise", "backgroundSaturation", "defaultFloatingX", "defaultFloatingY", "defaultFloatingRelativeTo"]);
if (isHyprland)
return keys.concat(hyprOptions.map(option => option.value), hyprEffectOptions.map(option => option.value), ["sizeWidth", "sizeHeight", "moveX", "moveY", "monitor", "workspace", "focusOnActivate", "scrollingWidth", "tonemap", "borderColor", "suppressEvent"]);
if (isMango)
return keys.concat(mangoOptions.map(option => option.value), ["workspace", "monitor", "sizeWidth", "sizeHeight"]);
return keys;
}
function collectActions() {
const actions = WindowRuleMerge.carry(storedActions, ownedActionKeys());
const has = value => openingFlags.includes(value);
if (opacityOn)
actions.opacity = opacityValue / 100;
if (isNiri)
applyCond(actions, "openFloating", floatingTri);
else if (has("float"))
actions.openFloating = true;
if (has("maximize"))
actions.openMaximized = true;
if (has("maxEdges") && isNiri)
actions.openMaximizedToEdges = true;
if (has("fullscreen"))
actions.openFullscreen = true;
if (has("focus") && isNiri)
actions.openFocused = true;
if (isNiri && outputInput.text.trim())
actions.openOnOutput = outputInput.text.trim();
if (isNiri && workspaceInput.text.trim())
actions.openOnWorkspace = workspaceInput.text.trim();
if (cornerRadiusOn)
actions.cornerRadius = cornerRadiusValue;
const minW = parseInt(minWidthInput.text);
const maxW = parseInt(maxWidthInput.text);
const minH = parseInt(minHeightInput.text);
const maxH = parseInt(maxHeightInput.text);
if (isNiri) {
if (!isNaN(minW))
actions.minWidth = minW;
if (!isNaN(maxW))
actions.maxWidth = maxW;
if (!isNaN(minH))
actions.minHeight = minH;
if (!isNaN(maxH))
actions.maxHeight = maxH;
}
if (isNiri) {
const columnWidth = WindowRuleSize.format(columnWidthUnit, columnWidthInput.text);
const windowHeight = WindowRuleSize.format(windowHeightUnit, windowHeightInput.text);
if (columnWidth)
actions.defaultColumnWidth = columnWidth;
if (windowHeight)
actions.defaultWindowHeight = windowHeight;
if (dynamicFlags.includes("vrr"))
actions.variableRefreshRate = true;
if (dynamicFlags.includes("clip"))
actions.clipToGeometry = true;
if (dynamicFlags.includes("tiled"))
actions.tiledState = true;
if (dynamicFlags.includes("borderBg"))
actions.drawBorderWithBackground = true;
if (blockOutValue)
actions.blockOutFrom = blockOutValue;
if (columnDisplayValue)
actions.defaultColumnDisplay = columnDisplayValue;
if (scrollFactorOn)
actions.scrollFactor = scrollFactorValue / 100;
applyCond(actions, "backgroundBlur", blurCond.triState);
applyCond(actions, "backgroundXray", xrayCond.triState);
if (noiseOn)
actions.backgroundNoise = noiseValue / 100;
if (saturationOn)
actions.backgroundSaturation = saturationValue / 100;
const floatX = parseInt(floatingXInput.text);
const floatY = parseInt(floatingYInput.text);
if (!isNaN(floatX) && !isNaN(floatY)) {
actions.defaultFloatingX = floatX;
actions.defaultFloatingY = floatY;
if (floatingRelativeValue !== "top-left")
actions.defaultFloatingRelativeTo = floatingRelativeValue;
}
}
if (isHyprland) {
for (const flag of hyprFlags)
actions[flag] = true;
if (sizeWInput.text.trim())
actions.sizeWidth = sizeWInput.text.trim();
if (sizeHInput.text.trim())
actions.sizeHeight = sizeHInput.text.trim();
if (moveXInput.text.trim())
actions.moveX = moveXInput.text.trim();
if (moveYInput.text.trim())
actions.moveY = moveYInput.text.trim();
if (monitorInput.text.trim())
actions.monitor = monitorInput.text.trim();
if (hyprWorkspaceInput.text.trim())
actions.workspace = hyprWorkspaceInput.text.trim();
for (const flag of hyprEffectFlags)
actions[flag] = true;
applyCond(actions, "focusOnActivate", focusOnActivateTri);
if (scrollingWidthOn)
actions.scrollingWidth = scrollingWidthValue / 100;
if (tonemapValue)
actions.tonemap = tonemapValue;
if (borderColorInput.text.trim())
actions.borderColor = borderColorInput.text.trim();
if (suppressEventInput.text.trim())
actions.suppressEvent = suppressEventInput.text.trim();
}
if (isMango) {
for (const flag of mangoFlags)
actions[flag] = true;
if (mangoTagsInput.text.trim())
actions.workspace = mangoTagsInput.text.trim();
if (mangoMonitorInput.text.trim())
actions.monitor = mangoMonitorInput.text.trim();
const parts = mangoSizeInput.text.trim().split(/x/i);
if (mangoSizeInput.text.trim() && parts.length === 2) {
actions.sizeWidth = parts[0].trim();
actions.sizeHeight = parts[1].trim();
}
}
return actions;
}
function submit() {
if (submitting || !acceptEnabled)
return;
const {
matchCriteria,
matches
} = collectMatches();
const ruleData = {
name: nameInput.text.trim() || matchCriteria.appId || I18n.tr("Rule", "noun, fallback name for an unnamed window rule"),
matchCriteria: matchCriteria,
actions: collectActions(),
enabled: true
};
if (isNiri && extraMatchModel.count > 0)
ruleData.matches = matches;
// No exclude editor yet; carry existing excludes through so an edit doesn't delete them (#2996)
if (isEditMode && (editingRule.excludes?.length ?? 0) > 0)
ruleData.excludes = editingRule.excludes;
const compositor = CompositorService.compositor;
const ruleJson = JSON.stringify(ruleData);
const args = isEditMode ? [Proc.dmsBin, "config", "windowrules", "update", compositor, editingRule.id, ruleJson] : [Proc.dmsBin, "config", "windowrules", "add", compositor, ruleJson];
submitting = true;
Proc.runCommand(isEditMode ? "update-windowrule" : "add-windowrule", args, (output, exitCode) => {
root.submitting = false;
if (exitCode !== 0)
return;
CompositorService.reloadAfterWindowRuleWrite();
root.ruleSubmitted();
});
}
component MatchCond: StyledButton {
id: mc
property string label: ""
property string key: ""
property int triState: 0
property string unsetLabel: I18n.tr("Default")
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: Theme.buttonHeightXS
radius: Theme.cornerRadiusS
color: enabled ? Theme.chipSurface : Theme.onSurface_12
border.width: Theme.outlineWidth
border.color: !enabled ? "transparent" : mc.triState === 0 ? Theme.outlineVariant : mc.stateColor[mc.triState]
enabled: root.fieldsEnabled
Accessible.role: Accessible.Button
Accessible.name: label + ": " + stateText[triState]
onClicked: triState = (triState + 1) % 3
Row {
id: condRow
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: mc.label
font.pixelSize: Theme.fontSizeSmall
color: mc.enabled ? Theme.onSurface : Theme.onSurface_38
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: stateBadge.implicitWidth + Theme.spacingS * 2
height: stateBadge.implicitHeight + Theme.spacingXXS * 2
radius: Theme.fullRadius(width, height)
color: mc.enabled ? Theme.withAlpha(mc.stateColor[mc.triState], Theme.stateLayerPressed) : "transparent"
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: stateBadge
anchors.centerIn: parent
text: mc.stateText[mc.triState]
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightMedium
color: mc.enabled ? mc.stateColor[mc.triState] : Theme.onSurface_38
}
}
}
StateLayer {
control: mc
disabled: !mc.enabled
stateColor: Theme.primary
}
FocusRing {
visible: mc.visualFocus
}
}
component FieldRow: SettingsRow {
default property alias fields: fieldRow.data
body: Row {
id: fieldRow
width: parent.width
spacing: Theme.spacingS
}
}
component Field: DTextField {
property int share: 1
property int shares: 2
width: Math.round((parent.width - parent.spacing * (shares - 1)) * share / shares)
outlined: true
enabled: root.fieldsEnabled
onAccepted: root.submit()
}
component SizeField: Field {
id: sizeField
property string unit: WindowRuleSize.PIXELS
property string pixelsPlaceholder: ""
signal unitSelected(string unit)
placeholderText: unit === WindowRuleSize.PERCENT ? "50" : pixelsPlaceholder
rightAccessoryWidth: unitPicker.width + Theme.spacingS
DButtonGroup {
id: unitPicker
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
y: sizeField.containerTop + (sizeField.containerHeight - height) / 2
size: "small"
checkEnabled: false
arrowKeysSelect: false
enabled: sizeField.enabled
model: [I18n.tr("px"), WindowRuleSize.PERCENT]
currentIndex: sizeField.unit === WindowRuleSize.PERCENT ? 1 : 0
onSelectionChanged: (index, selected) => {
if (selected)
sizeField.unitSelected(index === 1 ? WindowRuleSize.PERCENT : WindowRuleSize.PIXELS);
}
}
}
readonly property var matchConditions: [condFloating, condActive, condFocused, condActiveInColumn, condCastTarget, condUrgent, condAtStartup, condXwayland, condFullscreen, condPinned, condGroup, condModal]
actions: [
DButton {
text: I18n.tr("Cancel")
enabled: root.closeEnabled
backgroundColor: "transparent"
textColor: Theme.primary
onClicked: root.rejected()
},
DButton {
text: root.isEditMode ? I18n.tr("Save") : I18n.tr("Add")
enabled: root.acceptEnabled
busy: root.submitting
onClicked: root.submit()
}
]
SettingsGroup {
SettingsRow {
body: DTextField {
id: nameInput
width: parent.width
outlined: true
leftIconName: "edit"
labelText: I18n.tr("Rule Name")
enabled: root.fieldsEnabled
onAccepted: root.submit()
}
}
}
SettingsCard {
title: I18n.tr("Match Criteria")
headerActions: DButton {
visible: root.isNiri
text: I18n.tr("Add match")
iconName: "add"
buttonHeight: Theme.buttonHeightXS
backgroundColor: "transparent"
textColor: Theme.primary
enabled: root.fieldsEnabled
onClicked: extraMatchModel.append({
"rowAppId": "",
"rowTitle": "",
"rowStored": "{}"
})
}
SettingsRow {
visible: root.isNiri
subtitle: I18n.tr("The rule applies to any window matching one of these.")
}
SettingsRow {
body: DTextField {
id: appIdInput
width: parent.width
outlined: true
leftIconName: "apps"
labelText: root.isMango ? I18n.tr("App ID (e.g. firefox)") : root.isHyprland ? I18n.tr("Class regex (e.g. ^firefox$)") : I18n.tr("App ID regex (e.g. ^firefox$)")
enabled: root.fieldsEnabled
onAccepted: root.submit()
}
}
FieldRow {
Field {
id: titleInput
width: parent.width - (addTitle.visible ? addTitle.width + parent.spacing : 0)
leftIconName: "title"
labelText: root.isMango ? I18n.tr("Title (optional)") : I18n.tr("Title regex (optional)")
}
DActionButton {
id: addTitle
anchors.verticalCenter: parent.verticalCenter
iconName: "add"
visible: !root.isEditMode && !!root.targetWindow?.title
tooltipText: I18n.tr("Add Title")
onClicked: titleInput.text = root.isMango ? root.targetWindow.title : "^" + root.targetWindow.title + "$"
}
}
Repeater {
model: extraMatchModel
delegate: FieldRow {
id: extraRow
required property int index
required property string rowAppId
required property string rowTitle
Field {
width: Math.round((parent.width - parent.spacing * 2 - removeMatch.width) / 2)
leftIconName: "apps"
labelText: I18n.tr("App ID regex")
text: extraRow.rowAppId
onTextEdited: extraMatchModel.setProperty(extraRow.index, "rowAppId", text)
}
Field {
width: Math.round((parent.width - parent.spacing * 2 - removeMatch.width) / 2)
leftIconName: "title"
labelText: I18n.tr("Title regex (optional)")
text: extraRow.rowTitle
onTextEdited: extraMatchModel.setProperty(extraRow.index, "rowTitle", text)
}
DActionButton {
id: removeMatch
anchors.verticalCenter: parent.verticalCenter
iconName: "close"
Accessible.name: I18n.tr("Remove match")
onClicked: extraMatchModel.remove(extraRow.index)
}
}
}
}
SettingsCard {
title: I18n.tr("Match Conditions")
visible: root.isNiri || root.isHyprland
SettingsRow {
subtitle: I18n.tr("Optional state-based conditions applied to the first match.")
}
SettingsRow {
body: Flow {
width: parent.width
spacing: Theme.spacingS
MatchCond {
id: condFloating
key: "isFloating"
label: I18n.tr("Floating", "adjective, window rule match condition for floating windows")
}
MatchCond {
id: condActive
key: "isActive"
label: I18n.tr("Active")
visible: root.isNiri
}
MatchCond {
id: condFocused
key: "isFocused"
label: I18n.tr("Focused", "adjective, window rule match condition for the focused window")
visible: root.isNiri || root.isHyprland
}
MatchCond {
id: condActiveInColumn
key: "isActiveInColumn"
label: I18n.tr("Active in column")
visible: root.isNiri
}
MatchCond {
id: condCastTarget
key: "isWindowCastTarget"
label: I18n.tr("Cast target")
visible: root.isNiri
}
MatchCond {
id: condUrgent
key: "isUrgent"
label: I18n.tr("Urgent", "adjective, window rule match condition for windows requesting attention")
visible: root.isNiri
}
MatchCond {
id: condAtStartup
key: "atStartup"
label: I18n.tr("At startup")
visible: root.isNiri
}
MatchCond {
id: condXwayland
key: "xwayland"
label: "XWayland"
visible: root.isHyprland
}
MatchCond {
id: condFullscreen
key: "fullscreen"
label: I18n.tr("Fullscreen", "adjective, window rule match condition for fullscreen windows")
visible: root.isHyprland
}
MatchCond {
id: condPinned
key: "pinned"
label: I18n.tr("Pinned", "adjective, state of a pinned window, clipboard entry or item")
visible: root.isHyprland
}
MatchCond {
id: condGroup
key: "group"
label: I18n.tr("Group", "noun, window rule match condition for windows in a Hyprland group")
visible: root.isHyprland
}
MatchCond {
id: condModal
key: "modal"
label: I18n.tr("Modal", "adjective, window rule match condition for modal dialogs")
visible: root.isHyprland
}
}
}
}
SettingsCard {
id: hyprMatchCard
title: I18n.tr("Advanced Matching")
visible: root.isHyprland
collapsible: true
expanded: false
SettingsRow {
subtitle: I18n.tr("Prefix a tag with negative: to match windows without it.", "window rule editor hint, negative: is literal syntax")
}
FieldRow {
Field {
id: initialClassInput
leftIconName: "apps"
labelText: I18n.tr("Initial class")
placeholderText: "^steam$"
}
Field {
id: initialTitleInput
leftIconName: "title"
labelText: I18n.tr("Initial title")
placeholderText: "^Login$"
}
}
FieldRow {
Field {
id: matchTagInput
leftIconName: "sell"
labelText: I18n.tr("Tag", "noun, Hyprland window tag in window rule match")
placeholderText: "games"
}
Field {
id: xdgTagInput
leftIconName: "label"
labelText: I18n.tr("XDG tag")
placeholderText: "^portal$"
}
}
FieldRow {
Field {
id: matchWorkspaceInput
shares: 1
leftIconName: "view_module"
labelText: I18n.tr("Workspace")
placeholderText: "w[tv1]"
}
}
SettingsDropdownRow {
text: I18n.tr("Content", "noun, window content type match: photo, video or game")
options: root.contentOptions.map(root.defaultLabel)
currentValue: root.defaultLabel(root.contentValue)
enabled: root.fieldsEnabled
onValueChanged: value => root.contentValue = root.contentOptions.find(option => root.defaultLabel(option) === value) ?? ""
}
SettingsDropdownRow {
text: I18n.tr("Fullscreen state")
options: root.fullscreenStateLabels
currentValue: root.fullscreenStateLabels[root.fullscreenInternalValue + 1] ?? root.fullscreenStateLabels[0]
enabled: root.fieldsEnabled
onValueChanged: value => root.fullscreenInternalValue = root.fullscreenStateLabels.indexOf(value) - 1
}
SettingsDropdownRow {
text: I18n.tr("Client fullscreen state")
description: I18n.tr("The state the app believes it is in", "window rule match, Hyprland client fullscreen state")
options: root.fullscreenStateLabels
currentValue: root.fullscreenStateLabels[root.fullscreenClientValue + 1] ?? root.fullscreenStateLabels[0]
enabled: root.fieldsEnabled
onValueChanged: value => root.fullscreenClientValue = root.fullscreenStateLabels.indexOf(value) - 1
}
}
SettingsCard {
title: I18n.tr("Window Opening")
SettingsButtonGroupRow {
visible: root.isNiri
text: I18n.tr("Float")
description: I18n.tr("Default leaves it to niri, which floats dialogs and fixed-size windows and tiles the rest", "window rule float option, explains the Default choice")
model: root.triLabels
currentIndex: root.floatingTri
enabled: root.fieldsEnabled
onSelectionChanged: (index, selected) => {
if (selected)
root.floatingTri = index;
}
}
SettingsRow {
body: DFilterChips {
width: parent.width
multiSelect: true
showCounts: false
enabled: root.fieldsEnabled
model: root.openingOptions
selectedValues: root.openingFlags
onSelectionToggled: (index, selected) => root.openingFlags = root.flagSet(root.openingFlags, root.openingOptions[index].value, selected)
}
}
FieldRow {
visible: root.isNiri
Field {
id: outputInput
leftIconName: "monitor"
labelText: I18n.tr("Output", "noun, display output a window opens on, window rule field")
placeholderText: "HDMI-A-1"
}
Field {
id: workspaceInput
leftIconName: "view_module"
labelText: I18n.tr("Workspace")
placeholderText: "chat"
}
}
FieldRow {
visible: root.isNiri
SizeField {
id: columnWidthInput
leftIconName: "width"
labelText: I18n.tr("Column Width")
pixelsPlaceholder: "800"
unit: root.columnWidthUnit
onUnitSelected: unit => root.columnWidthUnit = unit
}
SizeField {
id: windowHeightInput
leftIconName: "height"
labelText: I18n.tr("Window Height")
pixelsPlaceholder: "600"
unit: root.windowHeightUnit
onUnitSelected: unit => root.windowHeightUnit = unit
}
}
}
SettingsCard {
title: I18n.tr("Dynamic Properties")
visible: root.isNiri || root.isHyprland
SettingsToggleSliderRow {
text: I18n.tr("Opacity")
checked: root.opacityOn
value: root.opacityValue
minimum: 10
unit: "%"
enabled: root.fieldsEnabled
onToggled: checked => root.opacityOn = checked
onSliderValueChanged: newValue => root.opacityValue = newValue
}
SettingsToggleSliderRow {
text: I18n.tr("Corner radius")
checked: root.cornerRadiusOn
value: root.cornerRadiusValue
maximum: 20
unit: "px"
enabled: root.fieldsEnabled
onToggled: checked => root.cornerRadiusOn = checked
onSliderValueChanged: newValue => root.cornerRadiusValue = newValue
}
SettingsToggleSliderRow {
visible: root.isNiri
text: I18n.tr("Scroll Factor")
checked: root.scrollFactorOn
value: root.scrollFactorValue
minimum: 10
maximum: 200
unit: "%"
enabled: root.fieldsEnabled
onToggled: checked => root.scrollFactorOn = checked
onSliderValueChanged: newValue => root.scrollFactorValue = newValue
}
SettingsRow {
visible: root.isNiri
body: DFilterChips {
width: parent.width
multiSelect: true
showCounts: false
enabled: root.fieldsEnabled
model: root.dynamicOptions
selectedValues: root.dynamicFlags
onSelectionToggled: (index, selected) => root.dynamicFlags = root.flagSet(root.dynamicFlags, root.dynamicOptions[index].value, selected)
}
}
SettingsDropdownRow {
visible: root.isNiri
text: I18n.tr("Block Out From")
options: root.blockOutOptions.map(root.blockOutLabel)
currentValue: root.blockOutLabel(root.blockOutValue)
enabled: root.fieldsEnabled
onValueChanged: value => root.blockOutValue = root.blockOutOptions.find(option => root.blockOutLabel(option) === value) ?? ""
}
SettingsDropdownRow {
visible: root.isNiri
text: I18n.tr("Column Display")
options: root.columnDisplayOptions.map(root.columnDisplayLabel)
currentValue: root.columnDisplayLabel(root.columnDisplayValue)
enabled: root.fieldsEnabled
onValueChanged: value => root.columnDisplayValue = root.columnDisplayOptions.find(option => root.columnDisplayLabel(option) === value) ?? ""
}
}
SettingsCard {
title: I18n.tr("Background Effect")
visible: root.isNiri
SettingsRow {
subtitle: 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).")
}
SettingsRow {
body: Flow {
width: parent.width
spacing: Theme.spacingS
MatchCond {
id: blurCond
label: I18n.tr("Blur", "noun, background blur effect option")
unsetLabel: I18n.tr("Inherit")
}
MatchCond {
id: xrayCond
label: I18n.tr("X-Ray", "window rule background xray effect option")
unsetLabel: I18n.tr("Inherit")
}
}
}
SettingsToggleSliderRow {
text: I18n.tr("Noise", "window rule background noise effect checkbox")
checked: root.noiseOn
value: root.noiseValue
unit: "%"
enabled: root.fieldsEnabled
onToggled: checked => root.noiseOn = checked
onSliderValueChanged: newValue => root.noiseValue = newValue
}
SettingsToggleSliderRow {
text: I18n.tr("Saturation", "window rule background color saturation checkbox")
checked: root.saturationOn
value: root.saturationValue
maximum: 200
unit: "%"
enabled: root.fieldsEnabled
onToggled: checked => root.saturationOn = checked
onSliderValueChanged: newValue => root.saturationValue = newValue
}
}
SettingsCard {
title: I18n.tr("Floating Position")
visible: root.isNiri
SettingsRow {
subtitle: I18n.tr("Initial position for floating windows. Set both X and Y; anchor controls which corner/edge they're relative to.")
}
FieldRow {
Field {
id: floatingXInput
leftIconName: "open_with"
labelText: "X"
placeholderText: "px"
}
Field {
id: floatingYInput
leftIconName: "open_with"
labelText: "Y"
placeholderText: "px"
}
}
SettingsDropdownRow {
text: I18n.tr("Anchor", "noun, screen corner or edge a floating window position is relative to")
options: root.anchorOptions
currentValue: root.floatingRelativeValue
enabled: root.fieldsEnabled
onValueChanged: value => root.floatingRelativeValue = value
}
}
SettingsCard {
title: I18n.tr("Size Constraints")
visible: root.isNiri
FieldRow {
Field {
id: minWidthInput
leftIconName: "width"
labelText: I18n.tr("Min W")
placeholderText: "px"
}
Field {
id: maxWidthInput
leftIconName: "width"
labelText: I18n.tr("Max W")
placeholderText: "px"
}
}
FieldRow {
Field {
id: minHeightInput
leftIconName: "height"
labelText: I18n.tr("Min H")
placeholderText: "px"
}
Field {
id: maxHeightInput
leftIconName: "height"
labelText: I18n.tr("Max H")
placeholderText: "px"
}
}
}
SettingsCard {
title: I18n.tr("Hyprland Options")
visible: root.isHyprland
SettingsRow {
body: DFilterChips {
width: parent.width
multiSelect: true
showCounts: false
enabled: root.fieldsEnabled
model: root.hyprOptions
selectedValues: root.hyprFlags
onSelectionToggled: (index, selected) => root.hyprFlags = root.flagSet(root.hyprFlags, root.hyprOptions[index].value, selected)
}
}
FieldRow {
Field {
id: moveXInput
leftIconName: "open_with"
labelText: "X"
placeholderText: "0"
}
Field {
id: moveYInput
leftIconName: "open_with"
labelText: "Y"
placeholderText: "0"
}
}
FieldRow {
Field {
id: sizeWInput
leftIconName: "width"
labelText: I18n.tr("W")
placeholderText: "800"
}
Field {
id: sizeHInput
leftIconName: "height"
labelText: I18n.tr("H", "abbreviation of height, window rule size field label")
placeholderText: "600"
}
}
FieldRow {
Field {
id: monitorInput
leftIconName: "monitor"
labelText: I18n.tr("Monitor", "noun, display output field in window rule editor")
placeholderText: "DP-1"
}
Field {
id: hyprWorkspaceInput
leftIconName: "view_module"
labelText: I18n.tr("Workspace")
placeholderText: "1"
}
}
}
SettingsCard {
id: hyprEffectsCard
title: I18n.tr("Advanced")
visible: root.isHyprland
collapsible: true
expanded: false
SettingsRow {
title: I18n.tr("Focus", "noun, window rule editor group of keyboard focus options")
body: DFilterChips {
width: parent.width
multiSelect: true
showCounts: false
enabled: root.fieldsEnabled
model: root.hyprFocusOptions
selectedValues: root.hyprEffectFlags
onSelectionToggled: (index, selected) => root.hyprEffectFlags = root.flagSet(root.hyprEffectFlags, root.hyprFocusOptions[index].value, selected)
}
}
SettingsButtonGroupRow {
text: I18n.tr("Focus on activate")
description: I18n.tr("Focus the window when it asks to be activated", "window rule option, Hyprland focus_on_activate")
model: root.triLabels
currentIndex: root.focusOnActivateTri
enabled: root.fieldsEnabled
onSelectionChanged: (index, selected) => {
if (selected)
root.focusOnActivateTri = index;
}
}
SettingsRow {
title: I18n.tr("Pointer")
body: DFilterChips {
width: parent.width
multiSelect: true
showCounts: false
enabled: root.fieldsEnabled
model: root.hyprPointerOptions
selectedValues: root.hyprEffectFlags
onSelectionToggled: (index, selected) => root.hyprEffectFlags = root.flagSet(root.hyprEffectFlags, root.hyprPointerOptions[index].value, selected)
}
}
SettingsRow {
title: I18n.tr("Rendering", "window rule editor group of render options")
body: DFilterChips {
width: parent.width
multiSelect: true
showCounts: false
enabled: root.fieldsEnabled
model: root.hyprRenderOptions
selectedValues: root.hyprEffectFlags
onSelectionToggled: (index, selected) => root.hyprEffectFlags = root.flagSet(root.hyprEffectFlags, root.hyprRenderOptions[index].value, selected)
}
}
SettingsDropdownRow {
text: I18n.tr("Tone mapping")
options: root.tonemapOptions.map(root.defaultLabel)
currentValue: root.defaultLabel(root.tonemapValue)
enabled: root.fieldsEnabled
onValueChanged: value => root.tonemapValue = root.tonemapOptions.find(option => root.defaultLabel(option) === value) ?? ""
}
SettingsToggleSliderRow {
text: I18n.tr("Column Width")
description: I18n.tr("Scrolling", "Hyprland tiling layout name")
checked: root.scrollingWidthOn
value: root.scrollingWidthValue
minimum: 10
maximum: 100
unit: "%"
enabled: root.fieldsEnabled
onToggled: checked => root.scrollingWidthOn = checked
onSliderValueChanged: newValue => root.scrollingWidthValue = newValue
}
FieldRow {
Field {
id: borderColorInput
leftIconName: "border_color"
labelText: I18n.tr("Border color")
placeholderText: "rgb(ff0000) rgb(880808)"
}
Field {
id: suppressEventInput
leftIconName: "block"
labelText: I18n.tr("Suppress events")
placeholderText: "maximize fullscreen"
}
}
}
SettingsCard {
title: I18n.tr("Mango Options")
visible: root.isMango
SettingsRow {
body: DFilterChips {
width: parent.width
multiSelect: true
showCounts: false
enabled: root.fieldsEnabled
model: root.mangoOptions
selectedValues: root.mangoFlags
onSelectionToggled: (index, selected) => root.mangoFlags = root.flagSet(root.mangoFlags, root.mangoOptions[index].value, selected)
}
}
FieldRow {
Field {
id: mangoTagsInput
leftIconName: "view_module"
labelText: I18n.tr("Tags", "noun, mango compositor workspace tags field in window rule editor")
placeholderText: "1"
}
Field {
id: mangoMonitorInput
leftIconName: "monitor"
labelText: I18n.tr("Monitor")
placeholderText: "HDMI-A-1"
}
}
FieldRow {
Field {
id: mangoSizeInput
shares: 1
leftIconName: "aspect_ratio"
labelText: I18n.tr("Size")
placeholderText: "800x600"
}
}
}
}