feat(blur): add configurable blur strength slider in all supported compositors

- Updated Hyprland defaults
This commit is contained in:
purian23
2026-10-09 05:19:46 -04:00
parent 2747a75198
commit ea68399ef3
11 changed files with 157 additions and 34 deletions
+41
View File
@@ -0,0 +1,41 @@
.pragma library
// One shell-wide blur reach in logical px, solved into each compositor's own knobs.
// Hyprland's stock 8/1 reaches 16px and reads as plain transparency, so DMS defaults it near niri.
var HYPRLAND_DEFAULT = 48;
var NIRI_DEFAULT = 42;
var AQUEOUS_DEFAULT = 40;
function defaultReach(compositor) {
switch (compositor) {
case "hyprland":
return HYPRLAND_DEFAULT;
case "niri":
return NIRI_DEFAULT;
case "aqueous":
return AQUEOUS_DEFAULT;
default:
return 0;
}
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
// Dual kawase reach is 2 * size * (2^passes - 1); passes step up so size stays small enough to avoid banding
function hyprlandBlur(reach) {
const passes = reach <= 20 ? 1 : reach <= 60 ? 2 : reach <= 140 ? 3 : 4;
const size = clamp(Math.round(reach / (2 * ((1 << passes) - 1))), 1, 20);
return { size, passes };
}
// Same kawase family; offset is free on the GPU while passes are not, so passes stay at niri's default 3
function niriBlur(reach) {
return { offset: Math.round(reach / 14 * 10) / 10, passes: 3 };
}
// Separable gaussian whose width is independent of passes; sigma is about 0.26 * radius
function aqueousRadius(reach) {
return clamp(Math.round(reach / 4), 1, 128);
}
+21 -8
View File
@@ -328,6 +328,7 @@ Singleton {
property bool blurEnabled: Spec.SPEC.blurEnabled.def
onBlurEnabledChanged: saveSettings()
property int blurStrength: Spec.SPEC.blurStrength.def
property bool blurBorderSeeded: Spec.SPEC.blurBorderSeeded.def
onBlurBorderSeededChanged: saveSettings()
property bool blurForegroundLayers: Spec.SPEC.blurForegroundLayers.def
@@ -1419,6 +1420,7 @@ Singleton {
signal widgetDataChanged
signal workspaceIconsUpdated
signal compositorLayoutRefreshNeeded(bool frame)
signal blurStrengthRefreshNeeded
signal compositorInputRefreshNeeded
signal compositorCursorRefreshNeeded
signal notificationPopupsInvalidated
@@ -1464,6 +1466,10 @@ Singleton {
compositorInputRefreshNeeded();
}
function updateBlurStrength() {
blurStrengthRefreshNeeded();
}
function updateFrameCompositorLayout() {
compositorLayoutRefreshNeeded(true);
FrameTransitionState.begin();
@@ -1716,6 +1722,7 @@ Singleton {
"regenSystemThemes": regenSystemThemes,
"updateCompositorLayout": updateCompositorLayout,
"updateCompositorInput": updateCompositorInput,
"updateBlurStrength": updateBlurStrength,
"applyStoredIconTheme": applyStoredIconTheme,
"updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor,
@@ -2043,7 +2050,7 @@ Singleton {
function _saveSettings(userChanges) {
let reason = null;
if (isGreeterMode) {
reason = "running in greeter mode."
reason = "running in greeter mode.";
} else if (_loading) {
reason = "some files are being loaded";
} else if (_parseError) {
@@ -3736,7 +3743,7 @@ Singleton {
id: rightWidgetsModel
}
component SettingsFile : QtObject {
component SettingsFile: QtObject {
id: settingsFile
required property string filePath
@@ -3823,7 +3830,7 @@ Singleton {
_loadSettingsOrStartIfReady();
}
}
onLoadFailed: (error) => {
onLoadFailed: error => {
if (isGreeterMode) {
return;
}
@@ -3846,7 +3853,7 @@ Singleton {
unsavedUserChanges = _anySettingsFile(file => file.fileUnsavedUserChanges);
}
}
onSaveFailed: (error) => {
onSaveFailed: error => {
selfWrite = false;
if (error === FileViewError.PermissionDenied) {
isFileReadOnly = true;
@@ -3860,7 +3867,12 @@ Singleton {
}
}
enum Stage { Discovering = 0, Loading = 1, Partial = 2, Ready = 3 }
enum Stage {
Discovering = 0,
Loading = 1,
Partial = 2,
Ready = 3
}
property int _settingsStage: SettingsData.Stage.Discovering
property var _settingsFiles: new Map()
@@ -3982,7 +3994,9 @@ Singleton {
const filePath = folderModel.get(i, "filePath");
folderPathsSet.add(filePath);
if (!_settingsFiles.has(filePath)) {
listModel.append({ filePath });
listModel.append({
filePath
});
}
}
for (let i = listModel.count - 1; i >= 0; i--) {
@@ -3997,7 +4011,7 @@ Singleton {
interval: 50
repeat: false
running: false
onTriggered: _syncSettingsFilesModels();
onTriggered: _syncSettingsFilesModels()
}
ListModel {
id: settingsFilesListModel
@@ -4133,7 +4147,6 @@ Singleton {
}
}
Timer {
id: settingsSaveDebounce
interval: 50
@@ -497,6 +497,10 @@ var LOCAL_SPEC = {
blurEnabled: {
def: false
},
blurStrength: {
def: 0,
onChange: "updateBlurStrength"
},
blurBorderSeeded: {
def: false
},
@@ -186,14 +186,19 @@ Column {
}
}
SettingsToggleRow {
SettingsToggleSliderRow {
tab: "theme"
tags: ["blur", "background", "transparency", "glass", "frosted"]
tags: ["blur", "background", "transparency", "glass", "frosted", "strength", "radius", "size", "passes", "reach"]
settingKey: "blurEnabled"
valueKeys: ["blurStrength"]
text: I18n.tr("Background blur")
description: BlurService.available ? "" : I18n.tr("Your compositor does not support background blur (ext-background-effect-v1)")
description: BlurService.available ? I18n.tr("Sets the compositor's blur for every window, not just the shell", "Blur strength slider description") : I18n.tr("Your compositor does not support background blur (ext-background-effect-v1)")
checked: SettingsData.blurEnabled ?? false
enabled: BlurService.available
value: BlurService.strength
minimum: 16
maximum: 160
unit: "px"
onToggled: checked => {
SettingsData.set("blurEnabled", checked);
if (!checked || SettingsData.blurBorderSeeded)
@@ -202,6 +207,7 @@ Column {
SettingsData.set("blurBorderEnabled", true);
SettingsSearchService.navigateToSection("blurBorderEnabled");
}
onSliderValueChanged: newValue => SettingsData.set("blurStrength", newValue)
}
SettingsNavRow {
@@ -3,6 +3,8 @@ pragma ComponentBehavior: Bound
import Quickshell
import qs.Services
import qs.Common
import "../Common/BlurStrength.js" as BlurStrength
Singleton {
id: root
@@ -146,4 +148,24 @@ Singleton {
function apply(draft, callback) {
request("apply", draft, callback);
}
// 0 restores the schema default so a reset does not pin the last slider value in the user override
function applyBlurStrength() {
load((snapshot, error) => {
if (!snapshot)
return;
const field = snapshot.fields?.find(f => f.id === "blur.radius");
if (!field)
return;
const strength = SettingsData.blurStrength;
const radius = strength > 0 ? BlurStrength.aqueousRadius(strength) : field.default;
if (!Number.isFinite(radius) || radius === field.value)
return;
apply({
expected_generation: snapshot.generation,
create_user_override: true,
changes: [{ id: "blur.radius", value: radius }]
}, () => {});
});
}
}
+3
View File
@@ -6,6 +6,7 @@ import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import "../Common/BlurStrength.js" as BlurStrength
Singleton {
id: root
@@ -20,6 +21,8 @@ Singleton {
probe();
}
readonly property bool enabled: available && (SettingsData.blurEnabled ?? false)
readonly property int defaultStrength: BlurStrength.defaultReach(CompositorService.configKey)
readonly property int strength: SettingsData.blurStrength || defaultStrength
// These settings predate non-blurred surface borders, so keep their keys for compatibility.
readonly property color borderColor: {
@@ -2171,6 +2171,14 @@ Singleton {
MangoService.generateLayoutConfig();
}
function onBlurStrengthRefreshNeeded() {
if (root.isAqueous && typeof AqueousConfigService !== "undefined") {
AqueousConfigService.applyBlurStrength();
return;
}
onCompositorLayoutRefreshNeeded(false);
}
function onCompositorInputRefreshNeeded() {
if (root.isNiri && typeof NiriService !== "undefined")
NiriService.generateNiriInputConfig();
+9 -12
View File
@@ -9,6 +9,7 @@ import qs.Common
import "../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
import qs.Services
import "../Common/OutputModel.js" as OutputModel
import "../Common/BlurStrength.js" as BlurStrength
import "../Common/WorkspaceModel.js" as WorkspaceModel
Singleton {
@@ -548,16 +549,6 @@ hl.config({
${sections.join("\n")}
})
`;
// Shell windows stay rigid like the rest of the shell chrome
if (SettingsData.hyprlandWobbleEnabled && hyprSupports("decoration:wobble:enabled")) {
content += `
hl.window_rule({
match = { class = "^(com\\\\.danklinux\\\\.dms)$" },
no_wobble = true,
})
`;
}
if (layoutXrayEnabled) {
content += `
hl.layer_rule({
@@ -622,11 +613,17 @@ hl.layer_rule({
}
function blurLines(s) {
const blur = BlurStrength.hyprlandBlur(s.blurStrength || BlurStrength.HYPRLAND_DEFAULT);
const lines = [];
if (blur.size !== 8)
lines.push(`size = ${blur.size},`);
if (blur.passes !== 1)
lines.push(`passes = ${blur.passes},`);
const variant = s.hyprlandBlurVariant;
if (!variant || variant === "kawase" || !hyprSupports("decoration:blur:variant"))
return [];
return lines;
// A user config with blur disabled would otherwise hide the chosen variant
const lines = ["enabled = true,", `variant = ${luaString(variant)},`];
lines.push("enabled = true,", `variant = ${luaString(variant)},`);
switch (variant) {
case "ripple":
return lines.concat(luaTable("ripple", [`strength = ${s.hyprlandBlurRippleStrength},`]));
+15 -1
View File
@@ -9,6 +9,7 @@ import qs.Common
import qs.Services
import qs.DCommon.Common as DCommon
import "../Common/OutputModel.js" as OutputModel
import "../Common/BlurStrength.js" as BlurStrength
Singleton {
id: root
@@ -1214,6 +1215,19 @@ Singleton {
configGenerationAction.schedule();
}
// niri's own defaults stay untouched until the user moves the shell-wide slider
function niriBlurBlock(strength) {
if (!(strength > 0))
return "";
const blur = BlurStrength.niriBlur(strength);
return `
blur {
passes ${blur.passes}
offset ${blur.offset}
}`;
}
function doGenerateNiriLayoutConfig() {
if (writeConfigProcess.running || _awaitingLayoutReloadRevision > _layoutAppliedRevision)
return;
@@ -1285,7 +1299,7 @@ window-rule {
clip-to-geometry true
tiled-state true
draw-border-with-background false
}` + xrayRules;
}` + niriBlurBlock(typeof SettingsData !== "undefined" ? SettingsData.blurStrength : 0) + xrayRules;
const alttabContent = dmsWarning + `recent-windows {
highlight {
+13 -4
View File
@@ -5,6 +5,7 @@ import vm from "node:vm";
import { loadScript } from "./qml-script.mjs";
const OutputModel = loadScript(new URL("../Common/OutputModel.js", import.meta.url));
const BlurStrength = loadScript(new URL("../Common/BlurStrength.js", import.meta.url));
const SPEC = loadScript(new URL("../Common/settings/SettingsSpec.js", import.meta.url)).SPEC;
const source = readFileSync(new URL("../Services/HyprlandService.qml", import.meta.url), "utf8");
const extract = name => {
@@ -18,6 +19,7 @@ const live = {};
// Every option is reported as supported unless a test shrinks the set
const scope = vm.createContext({
OutputModel,
BlurStrength,
SettingsData: { displayNameMode: "system" },
liveMonitor: name => live[name] ?? null,
hyprOptionNames: new Proxy({}, { get: (names, key) => key in names ? names[key] : true })
@@ -43,7 +45,7 @@ const settingsFor = prefix => {
const defaults = Object.fromEntries(Object.keys(SPEC).filter(key => prefix.test(key)).map(key => [key, SPEC[key].def]));
return overrides => Object.assign({}, defaults, overrides);
};
const layout = settingsFor(/^hyprland/);
const layout = settingsFor(/^(hyprland|blurStrength)/);
const input = settingsFor(/^(mouse|touchpad|keyboard)[A-Z]/);
const table = (lines, name) => {
const start = lines.indexOf(`${name} = {`);
@@ -108,17 +110,24 @@ test("outputs: written scale snaps to Hyprland's n/120 grid so live float32 scal
});
test("layout: a blur variant forces blur on and writes only its own tuning table", () => {
assert.equal(table(decorationLines(layout({ hyprlandBlurVariant: "kawase" }), 0), "blur"), null);
const frost = table(decorationLines(layout({ hyprlandBlurVariant: "frost" }), 0), "blur");
assert.equal(table(decorationLines(layout({ hyprlandBlurVariant: "kawase", blurStrength: 16 }), 0), "blur"), null);
const frost = table(decorationLines(layout({ hyprlandBlurVariant: "frost", blurStrength: 16 }), 0), "blur");
assert.deepEqual(frost, ["enabled = true,", 'variant = "frost",']);
const aurora = table(decorationLines(layout({ hyprlandBlurVariant: "aurora" }), 0), "blur");
const aurora = table(decorationLines(layout({ hyprlandBlurVariant: "aurora", blurStrength: 16 }), 0), "blur");
assert.deepEqual(aurora.slice(0, 2), ["enabled = true,", 'variant = "aurora",']);
assert.deepEqual(table(aurora, "aurora"), ["intensity = 0.35,", "speed = 1,"]);
assert.equal(table(aurora, "haze"), null);
});
test("layout: blur strength writes Hyprland's size and passes, default reach gives two passes and skips the stock size", () => {
assert.deepEqual(table(decorationLines(layout(), 0), "blur"), ["passes = 2,"]);
assert.equal(table(decorationLines(layout({ blurStrength: 16 }), 0), "blur"), null);
assert.deepEqual(table(decorationLines(layout({ blurStrength: 112, hyprlandBlurVariant: "frost" }), 0), "blur"), ["passes = 3,", "enabled = true,", 'variant = "frost",']);
});
test("layout: effects the running Hyprland does not know are not written even when enabled", () => {
const enabled = layout({
blurStrength: 16,
hyprlandBlurVariant: "aurora",
hyprlandGlowEnabled: true,
hyprlandWobbleEnabled: true,
@@ -251,18 +251,24 @@
"keywords": [
"background",
"blur",
"compositor",
"does",
"effect",
"every",
"frosted",
"glass",
"interface",
"just",
"passes",
"radius",
"reach",
"sets",
"shell",
"size",
"strength",
"style",
"support",
"transparency"
"transparency",
"window"
],
"page": "theme_surfaces",
"description": "Your compositor does not support background blur (ext-background-effect-v1)"
"description": "Sets the compositor's blur for every window, not just the shell"
},
{
"section": "wallpaperBackgroundColorMode",