From ea68399ef361d5f9d817698f48d4b76db95be890 Mon Sep 17 00:00:00 2001 From: purian23 Date: Fri, 9 Oct 2026 05:19:46 -0400 Subject: [PATCH] feat(blur): add configurable blur strength slider in all supported compositors - Updated Hyprland defaults --- quickshell/Common/BlurStrength.js | 41 +++++++++++++++++++ quickshell/Common/SettingsData.qml | 29 +++++++++---- quickshell/Common/settings/SettingsSpec.js | 4 ++ .../Modules/Settings/ThemeSurfacesTab.qml | 12 ++++-- quickshell/Services/AqueousConfigService.qml | 22 ++++++++++ quickshell/Services/BlurService.qml | 3 ++ quickshell/Services/CompositorService.qml | 8 ++++ quickshell/Services/HyprlandService.qml | 21 ++++------ quickshell/Services/NiriService.qml | 16 +++++++- quickshell/tests/hyprland-lua.test.mjs | 17 ++++++-- .../translations/settings_search_index.json | 18 +++++--- 11 files changed, 157 insertions(+), 34 deletions(-) create mode 100644 quickshell/Common/BlurStrength.js diff --git a/quickshell/Common/BlurStrength.js b/quickshell/Common/BlurStrength.js new file mode 100644 index 000000000..6cc6f9dfa --- /dev/null +++ b/quickshell/Common/BlurStrength.js @@ -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); +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 74dd0e42e..e499e97f3 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -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 diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 1c5f49150..2e7a19d50 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -497,6 +497,10 @@ var LOCAL_SPEC = { blurEnabled: { def: false }, + blurStrength: { + def: 0, + onChange: "updateBlurStrength" + }, blurBorderSeeded: { def: false }, diff --git a/quickshell/Modules/Settings/ThemeSurfacesTab.qml b/quickshell/Modules/Settings/ThemeSurfacesTab.qml index de2fd48b0..4bbf2b7ef 100644 --- a/quickshell/Modules/Settings/ThemeSurfacesTab.qml +++ b/quickshell/Modules/Settings/ThemeSurfacesTab.qml @@ -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 { diff --git a/quickshell/Services/AqueousConfigService.qml b/quickshell/Services/AqueousConfigService.qml index 1957545c3..e4ea816ae 100644 --- a/quickshell/Services/AqueousConfigService.qml +++ b/quickshell/Services/AqueousConfigService.qml @@ -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 }] + }, () => {}); + }); + } } diff --git a/quickshell/Services/BlurService.qml b/quickshell/Services/BlurService.qml index 43a98415f..3c96fafec 100644 --- a/quickshell/Services/BlurService.qml +++ b/quickshell/Services/BlurService.qml @@ -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: { diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index 598600d2a..4acc23405 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -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(); diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 82ff285bf..326ae20e1 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -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},`])); diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index a39867bfc..2634e324f 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -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 { diff --git a/quickshell/tests/hyprland-lua.test.mjs b/quickshell/tests/hyprland-lua.test.mjs index e492ec070..750f29d3b 100644 --- a/quickshell/tests/hyprland-lua.test.mjs +++ b/quickshell/tests/hyprland-lua.test.mjs @@ -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, diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 897139f04..e237e87ef 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -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",