Files
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

401 lines
15 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.Common
import qs.Modules.DBar
import qs.Services
import qs.DCommon.Widgets
import qs.Widgets
DPopout {
id: root
property var currentWindow: null
property int processId: 0
readonly property string appId: currentWindow?.appId || ""
readonly property string windowTitle: currentWindow?.title || ""
readonly property string appName: appId ? Paths.getAppName(appId, DesktopEntries.heuristicLookup(Paths.moddedAppId(appId))) : I18n.tr("Unknown")
readonly property int pid: processId
readonly property string scratchpadName: CompositorService.windowScratchpadName(currentWindow)
layerNamespace: "dms:focused-window-popout"
popupWidth: 340
popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 260
triggerWidth: 40
positioning: ""
shouldBeVisible: false
property var matchingRule: null
readonly property bool hasMatchingRule: matchingRule !== null
function testPattern(pattern, value) {
if (!pattern)
return true;
try {
return new RegExp(pattern).test(value || "");
} catch (e) {
return pattern === (value || "");
}
}
function matchesCriteria(match, window) {
if (!match || (!match.appId && !match.title))
return false;
return testPattern(match.appId, window.appId) && testPattern(match.title, window.title);
}
function findMatchingDmsRule(rules, window) {
if (!rules || !window)
return null;
return rules.find(rule => {
if (!(rule.source || "").includes("dms/windowrules") || [CompositorService.dmsFloatingRuleId, CompositorService.dmsOpaqueRuleId].includes(rule.id))
return false;
const matches = (rule.matches && rule.matches.length > 0) ? rule.matches : (rule.matchCriteria ? [rule.matchCriteria] : []);
return matches.some(m => matchesCriteria(m, window));
}) || null;
}
function checkMatchingRule() {
if (!shouldBeVisible || !currentWindow || !CompositorService.supportsWindowRules) {
matchingRule = null;
return;
}
matchingRule = null;
const targetWin = currentWindow;
const compositor = CompositorService.compositor;
Proc.runCommand("focused-window-rules", [Proc.dmsBin, "config", "windowrules", "list", compositor], (output, exitCode) => {
if (exitCode !== 0 || targetWin !== currentWindow)
return;
try {
const result = JSON.parse(output.trim());
matchingRule = findMatchingDmsRule(result.rules, currentWindow);
} catch (e) {
matchingRule = null;
}
});
}
onShouldBeVisibleChanged: {
if (shouldBeVisible)
checkMatchingRule();
}
onCurrentWindowChanged: {
if (shouldBeVisible)
checkMatchingRule();
}
function copyValue(value) {
if (!value)
return;
Quickshell.execDetached(["dms", "cl", "copy", value.toString()]);
ToastService.showInfo(I18n.tr("Copied to clipboard"));
close();
}
function killWindow() {
if (pid > 0)
Quickshell.execDetached(["kill", pid.toString()]);
close();
}
function addWindowRule() {
openWindowRule();
}
function openWindowRule() {
if (!currentWindow || !PopoutService.windowRuleModalLoader)
return;
const targetRule = root.matchingRule;
const targetWin = currentWindow;
close();
const loader = PopoutService.windowRuleModalLoader;
loader.active = true;
const openModal = () => {
if (!loader.item)
return;
if (targetRule)
loader.item.showEdit(targetRule);
else
loader.item.show(targetWin);
};
if (loader.item)
openModal();
else
Qt.callLater(openModal);
}
onBackgroundClicked: close()
content: Component {
Rectangle {
id: contentRoot
implicitWidth: 340
implicitHeight: contentColumn.implicitHeight + PopoutMetrics.contentPadding * 2
anchors.fill: parent
color: "transparent"
focus: true
Keys.onEscapePressed: event => {
root.close();
event.accepted = true;
}
Column {
id: contentColumn
anchors.fill: parent
anchors.margins: PopoutMetrics.contentPadding
spacing: Theme.spacingXXS
Row {
width: parent.width
height: BarMetrics.menuRowHeight
spacing: Theme.spacingS
IconImage {
width: Theme.iconSizeMedium
height: Theme.iconSizeMedium
source: Paths.getAppIcon(root.appId, DesktopEntries.heuristicLookup(root.appId))
asynchronous: true
mipmap: true
smooth: true
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
width: parent.width - Theme.iconSizeMedium - Theme.spacingS
text: root.appName
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.Wrap
clip: true
anchors.verticalCenter: parent.verticalCenter
}
}
Repeater {
model: [
{
label: I18n.tr("App ID"),
value: root.appId,
copyable: !!root.appId
},
{
label: I18n.tr("Title"),
value: root.windowTitle || I18n.tr("Untitled"),
copyable: !!root.windowTitle
},
{
label: I18n.tr("PID", "Label for the process ID row in the focused window popout"),
value: root.pid > 0 ? root.pid.toString() : I18n.tr("Unavailable"),
copyable: root.pid > 0
}
]
delegate: DListRow {
id: copyRow
required property var modelData
required property int index
readonly property real labelWidth: Theme.iconButtonSize + Theme.iconSizeLarge
width: contentColumn.width
implicitHeight: Theme.listItemHeight
firstInGroup: index === 0
lastInGroup: index === 2
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingS
StyledText {
width: copyRow.labelWidth
text: copyRow.modelData.label
color: copyRow.supportingContentColor
font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
width: parent.width - copyRow.labelWidth - Theme.spacingS
text: copyRow.modelData.value
color: copyRow.contentColor
font.pixelSize: Theme.fontSizeSmall
font.family: SettingsData.monoFontFamily
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideMiddle
clip: true
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
topLeftRadius: copyRow.firstInGroup ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius
topRightRadius: topLeftRadius
bottomLeftRadius: copyRow.lastInGroup ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius
bottomRightRadius: bottomLeftRadius
enabled: copyRow.modelData.copyable
disabled: !copyRow.modelData.copyable
onClicked: root.copyValue(copyRow.modelData.value)
}
}
}
Rectangle {
width: parent.width
height: Theme.dividerWidth
color: Theme.outlineVariant
}
Item {
visible: root.scratchpadName !== ""
width: parent.width
height: BarMetrics.menuRowHeight
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DIcon {
name: "outbox"
size: Theme.iconSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Move out of scratchpad", "window context menu action, brings the window back from the special workspace")
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
cornerRadius: BarMetrics.menuItemRadius
onClicked: {
CompositorService.moveWindowOutOfSpecial(root.currentWindow);
root.close();
}
}
}
Repeater {
model: root.scratchpadName !== "" ? [] : CompositorService.specialWorkspaceNames
Item {
required property string modelData
width: parent.width
height: BarMetrics.menuRowHeight
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DIcon {
name: "inbox"
size: Theme.iconSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData === "special" ? I18n.tr("Move to scratchpad", "window context menu action, sends the window to the unnamed special workspace") : I18n.tr("Move to scratchpad: %1", "%1 is the named special workspace").arg(modelData)
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
cornerRadius: BarMetrics.menuItemRadius
onClicked: {
CompositorService.moveWindowToSpecial(root.currentWindow, modelData);
root.close();
}
}
}
}
Item {
visible: CompositorService.supportsWindowRules
width: parent.width
height: BarMetrics.menuRowHeight
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DIcon {
name: root.hasMatchingRule ? "edit" : "rule"
size: Theme.iconSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.hasMatchingRule ? I18n.tr("Edit Window Rule") : I18n.tr("Add window rule")
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
cornerRadius: BarMetrics.menuItemRadius
onClicked: root.openWindowRule()
}
}
Item {
width: parent.width
height: BarMetrics.menuRowHeight
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DIcon {
name: "close"
size: Theme.iconSizeSmall
color: Theme.error
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Kill Process")
color: Theme.error
font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
cornerRadius: BarMetrics.menuItemRadius
stateColor: Theme.error
enabled: root.pid > 0
disabled: root.pid <= 0
onClicked: root.killWindow()
}
}
}
}
}
}