diff --git a/modules/common/Config.qml b/modules/common/Config.qml index 97f2c02f8..9788c78b0 100644 --- a/modules/common/Config.qml +++ b/modules/common/Config.qml @@ -171,6 +171,10 @@ Singleton { property int rounding: 22 property real activeOpacity: 1.0 property real inactiveOpacity: 0.9 + + // Per-application opacity settings. + // Each entry: { id, name, match, active, enabled } + property list applicationOpacityRules: [] property JsonObject blur: JsonObject { property bool enabled: true property int size: 1 diff --git a/modules/ii/settings/pages/HyprlandConfig.qml b/modules/ii/settings/pages/HyprlandConfig.qml index 79b686558..0a649ac97 100644 --- a/modules/ii/settings/pages/HyprlandConfig.qml +++ b/modules/ii/settings/pages/HyprlandConfig.qml @@ -61,6 +61,92 @@ ContentPage { "input:touchpad:scroll_factor": h.input.touchpad.scrollFactor }) } + // ------------------------------------------------------------------------- + // Per-application active opacity + // ------------------------------------------------------------------------- + + property int applicationOpacityRevision: 0 + property string applicationOpacitySearchText: "" + + function applicationOpacityRule(appId) { + const revision = applicationOpacityRevision + const rules = Config.options.hyprland.decoration.applicationOpacityRules || [] + + for (const rule of rules) { + if (rule && rule.id === appId) + return rule + } + + return null + } + + function saveApplicationOpacity(appEntry, enabled, activePercent) { + const rules = [...(Config.options.hyprland.decoration.applicationOpacityRules || [])] + const id = String(appEntry.id || "") + if (!id) + return + + const index = rules.findIndex(rule => rule && rule.id === id) + const existing = index >= 0 ? rules[index] : null + const match = String((existing && existing.match) || appEntry.startupClass || appEntry.id || "").trim() + if (!match) + return + + const numericActive = Number(activePercent) + const safeActive = Number.isFinite(numericActive) ? numericActive : 100 + + const rule = { + id: id, + name: String(appEntry.name || id), + match: match, + active: Math.max(10, Math.min(100, safeActive)) / 100.0, + enabled: Boolean(enabled) + } + + if (index >= 0) + rules[index] = rule + else + rules.push(rule) + + Config.options.hyprland.decoration.applicationOpacityRules = rules + applicationOpacityRevision++ + HyprlandConfig.syncApplicationOpacity() + } + + ScriptModel { + id: applicationOpacityApplications + + values: { + const revision = page.applicationOpacityRevision + const query = page.applicationOpacitySearchText.trim().toLowerCase() + + return [...DesktopEntries.applications.values] + .filter(entry => { + if (!entry || entry.noDisplay) + return false + if (!query) + return true + + const name = String(entry.name || "").toLowerCase() + const id = String(entry.id || "").toLowerCase() + const cls = String(entry.startupClass || "").toLowerCase() + + return name.includes(query) || id.includes(query) || cls.includes(query) + }) + .sort((a, b) => + String(a.name || a.id).localeCompare(String(b.name || b.id)) + ) + } + } + + Connections { + target: DesktopEntries + + function onApplicationsChanged() { + page.applicationOpacityRevision++ + } + } + MonitorConfigOption { id: monitorConfig } ColumnLayout { @@ -447,6 +533,243 @@ ContentPage { if (newVal === Config.options.hyprland.decoration.inactiveOpacity) return Config.options.hyprland.decoration.inactiveOpacity = newVal HyprlandConfig.set("decoration:inactive_opacity", newVal) + HyprlandConfig.syncApplicationOpacity() + } + } + } + } + + // Application Opacity + ContentSection { + icon: "opacity" + shape: MaterialShape.Shape.ClamShell + title: Translation.tr("Application Opacity") + Layout.fillWidth: true + + ContentSubsection { + title: Translation.tr("Installed Applications") + + ColumnLayout { + Layout.fillWidth: true + spacing: 7 + + // ------------------------------------------------ + // Compact search bar + // ------------------------------------------------ + Rectangle { + Layout.fillWidth: true + implicitHeight: 38 + + radius: Appearance.rounding.small + + color: ColorUtils.transparentize( + Appearance.colors.colPrimaryContainer, + 0.74 + ) + + TextInput { + id: applicationOpacitySearch + + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + + color: Appearance.colors.colOnSurface + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + + onTextChanged: + page.applicationOpacitySearchText = text + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + + text: + Translation.tr("Search applications...") + + color: + Appearance.colors.colOnSurface + + opacity: 0.48 + + visible: + applicationOpacitySearch.text.length === 0 + } + } + } + + // ------------------------------------------------ + // Installed applications + // ------------------------------------------------ + Repeater { + model: applicationOpacityApplications + + delegate: Rectangle { + Layout.fillWidth: true + implicitHeight: 58 + + radius: Appearance.rounding.small + + color: + Appearance.colors.colLayer1 + + border.width: 1 + border.color: + Appearance.colors.colLayer0Border + + property int revision: + page.applicationOpacityRevision + + property var savedRule: { + const r = revision + return page.applicationOpacityRule( + modelData.id + ) + } + + property bool overrideEnabled: + savedRule !== null && + savedRule.enabled === true + + property int activeOpacityValue: { + const rawValue = savedRule !== null + ? Number(savedRule.active) * 100 + : Number( + Config.options.hyprland.decoration.activeOpacity + ) * 100 + + if (!Number.isFinite(rawValue)) + return 100 + + return Math.max(10, Math.min(100, Math.round(rawValue))) + } + + RowLayout { + anchors.fill: parent + + anchors.leftMargin: 10 + anchors.rightMargin: 8 + + spacing: 6 + + // ------------------------------------------------ + // App name + class + // ------------------------------------------------ + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + + spacing: 0 + + Text { + Layout.fillWidth: true + + text: + modelData.name || + modelData.id + + color: + Appearance.colors.colOnSurface + + font.pixelSize: 13 + + elide: + Text.ElideRight + } + + Text { + Layout.fillWidth: true + + text: + modelData.startupClass || + modelData.id + + color: + Appearance.colors.colOnSurface + + opacity: 0.48 + + font.pixelSize: 10 + + elide: + Text.ElideRight + } + } + + // ------------------------------------------------ + // Per-app Override + // ------------------------------------------------ + ConfigSwitch { + Layout.preferredWidth: 78 + + text: + Translation.tr("Override") + + buttonIcon: "" + + checked: + overrideEnabled + + onCheckedChanged: { + if (checked === overrideEnabled) + return + + const active = + savedRule !== null + ? Math.round( + Number(savedRule.active) * + 100 + ) + : Math.round( + Config.options.hyprland + .decoration + .activeOpacity * 100 + ) + + page.saveApplicationOpacity( + modelData, + checked, + active + ) + } + } + + // ------------------------------------------------ + // Per-app active opacity + // ------------------------------------------------ + ConfigSpinBox { + Layout.preferredWidth: 92 + + icon: "" + text: "" + + enabled: overrideEnabled + + value: + activeOpacityValue + + from: 10 + to: 100 + stepSize: 5 + + onValueChanged: { + if (!overrideEnabled) + return + + if (value === activeOpacityValue) + return + + page.saveApplicationOpacity( + modelData, + true, + value + ) + } + } + } + } } } } diff --git a/scripts/appOpacitySync.py b/scripts/appOpacitySync.py new file mode 100755 index 000000000..1b25846f2 --- /dev/null +++ b/scripts/appOpacitySync.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 + +import argparse +import json +import math +import re +import subprocess +import os +import stat +import tempfile +from pathlib import Path + + +REQUIRE_LINE = 'require("hyprland/shellOverrides/appOpacity")' + + +def lua_quote(value: str) -> str: + return '"' + ( + str(value) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\r", "\\r") + .replace("\n", "\\n") + ) + '"' + + +def get_hyprland_clients(): + try: + result = subprocess.run( + ["hyprctl", "clients", "-j"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + clients = json.loads(result.stdout) + if isinstance(clients, list): + return clients + except Exception as exc: + print(f"Warning: could not read Hyprland clients: {exc}") + + return [] + + +def normalize(value: str) -> str: + return str(value or "").strip().lower() + + +def clamp_opacity(value, default): + try: + value = float(value) + except (TypeError, ValueError): + return default + + if not math.isfinite(value): + return default + + return max(0.10, min(1.00, value)) + + +def resolve_real_class(match: str, rule_id: str, clients): + target = normalize(match) + rule_id_normalized = normalize(rule_id) + classes = [] + + for client in clients: + cls = str(client.get("initialClass") or "").strip() + if cls and cls not in classes: + classes.append(cls) + + for cls in classes: + if normalize(cls) == target: + return cls + + for cls in classes: + if normalize(cls) == rule_id_normalized: + return cls + + for cls in classes: + n = normalize(cls) + + if n.endswith("." + target): + return cls + + if target.endswith("." + n): + return cls + + def compact(value): + return re.sub(r"[^a-z0-9]", "", normalize(value)) + + compact_target = compact(target) + + if compact_target: + for cls in classes: + if compact(cls) == compact_target: + return cls + + return match + + +def write_text(path: Path, content: str): + path.parent.mkdir(parents=True, exist_ok=True) + + if path.exists() and path.read_text() == content: + return False + + existing_mode = ( + stat.S_IMODE(path.stat().st_mode) + if path.exists() + else 0o644 + ) + + fd, temp_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + text=True, + ) + + try: + with os.fdopen(fd, "w") as temp_file: + temp_file.write(content) + temp_file.flush() + os.fsync(temp_file.fileno()) + + os.chmod(temp_name, existing_mode) + os.replace(temp_name, path) + return True + finally: + try: + os.unlink(temp_name) + except FileNotFoundError: + pass + + +def ensure_require(main_path: Path): + try: + content = main_path.read_text() + except FileNotFoundError: + content = "" + + lines = content.splitlines() + + if any(line.strip() == REQUIRE_LINE for line in lines): + return + + if content and not content.endswith("\n"): + content += "\n" + + content += REQUIRE_LINE + "\n" + + return write_text(main_path, content) + + +def build_application_opacity(config_path: Path, output_path: Path): + try: + data = json.loads(config_path.read_text()) + except Exception as exc: + print(f"Could not read {config_path}: {exc}") + return None + + decoration = ( + data.get("hyprland", {}) + .get("decoration", {}) + ) + + rules = decoration.get("applicationOpacityRules", []) + + inactive = clamp_opacity( + decoration.get("inactiveOpacity", 0.9), + 0.9, + ) + + if not isinstance(rules, list): + rules = [] + + clients = get_hyprland_clients() + + lines = [ + "-- AUTO-GENERATED by end4-pC Application Opacity.", + "-- Do not edit this file manually.", + "", + ] + + for rule in rules: + if not isinstance(rule, dict): + continue + + if not bool(rule.get("enabled", False)): + continue + + rule_id = str(rule.get("id", "")).strip() + saved_match = str(rule.get("match", "")).strip() + + if not rule_id and not saved_match: + continue + + real_class = resolve_real_class( + saved_match, + rule_id, + clients, + ) + + if not real_class: + continue + + active = clamp_opacity( + rule.get("active", 1.0), + 1.0, + ) + + safe_id = re.sub( + r"[^A-Za-z0-9_-]", + "_", + rule_id or real_class, + ) + + matcher = "^" + re.escape(real_class) + "$" + + lines.append( + "hl.window_rule({" + f" name = {lua_quote('end4-app-opacity-' + safe_id)}," + f" match = {{ initial_class = {lua_quote(matcher)} }}," + f" opacity = " + f"{lua_quote(f'{active:.2f} override {inactive:.2f} override')}" + " })" + ) + + return write_text( + output_path, + "\n".join(lines) + "\n", + ) + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument( + "--config", + required=True, + type=Path, + ) + + parser.add_argument( + "--output", + required=True, + type=Path, + ) + + parser.add_argument( + "--main", + required=True, + type=Path, + ) + + args = parser.parse_args() + + output_changed = build_application_opacity( + args.config, + args.output, + ) + + if output_changed is None: + return 1 + + require_changed = ensure_require(args.main) + + if not output_changed and not require_changed: + return 0 + + try: + result = subprocess.run( + ["hyprctl", "reload"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + except FileNotFoundError: + print("Could not reload Hyprland: hyprctl was not found in PATH") + return 1 + + if result.returncode != 0: + print( + result.stderr.strip() + or result.stdout.strip() + ) + return result.returncode + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/HyprlandConfig.qml b/services/HyprlandConfig.qml index 7f6c96181..3b5046c89 100644 --- a/services/HyprlandConfig.qml +++ b/services/HyprlandConfig.qml @@ -13,6 +13,8 @@ Singleton { readonly property string configuratorScriptPath: Quickshell.shellPath("scripts/hyprland/hyprconfigurator.py") readonly property string shellOverridesPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/shellOverrides/main.lua`) readonly property string animOverridesPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/shellOverrides/animations.lua`) + readonly property string applicationOpacityScriptPath: Quickshell.shellPath("scripts/appOpacitySync.py") + readonly property string applicationOpacityOutputPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/shellOverrides/appOpacity.lua`) function set(key: string, value: var) { Quickshell.execDetached([ @@ -54,6 +56,42 @@ Singleton { ]) } + function syncApplicationOpacity() { + if (!Config.ready) + return + + applicationOpacitySyncTimer.restart() + } + + function runApplicationOpacitySync() { + Quickshell.execDetached([ + "python3", + root.applicationOpacityScriptPath, + "--config", Config.filePath, + "--output", root.applicationOpacityOutputPath, + "--main", root.shellOverridesPath + ]) + } + + Timer { + id: applicationOpacitySyncTimer + interval: 300 + repeat: false + + onTriggered: root.runApplicationOpacitySync() + } + + Connections { + target: Config + + function onReadyChanged() { + if (Config.ready) + root.syncApplicationOpacity() + } + } + + Component.onCompleted: root.syncApplicationOpacity() + Connections { target: Hyprland function onRawEvent(event) {