Files
DankMaterialShell/quickshell/Modules/Settings/WindowRuleEditorDialog.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

1214 lines
42 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import "../../Common/WindowRuleSize.js" as WindowRuleSize
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
readonly property var triLabels: [I18n.tr("Default"), I18n.tr("On"), I18n.tr("Off")]
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 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;
for (const field of [outputInput, workspaceInput, columnWidthInput, windowHeightInput, floatingXInput, floatingYInput, minWidthInput, maxWidthInput, minHeightInput, maxHeightInput, moveXInput, moveYInput, sizeWInput, sizeHInput, monitorInput, hyprWorkspaceInput, mangoTagsInput, mangoMonitorInput, mangoSizeInput])
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] || {};
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 || ""
});
for (const cond of matchConditions)
cond.triState = triFromBool(match[cond.key]);
const actions = rule.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 || "";
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 matchCriteria = {};
if (appIdInput.text.trim())
matchCriteria.appId = appIdInput.text.trim();
if (titleInput.text.trim())
matchCriteria.title = titleInput.text.trim();
for (const cond of matchConditions) {
if (cond.visible)
applyCond(matchCriteria, cond.key, cond.triState);
}
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 = {};
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 collectActions() {
const actions = {};
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();
}
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]
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": ""
})
}
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
}
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
}
}
}
}
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 {
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"
}
}
}
}