From 441b86448e1910e57f9b94e36fa1f903f8a6fb51 Mon Sep 17 00:00:00 2001 From: Daniele Fucini Date: Fri, 23 Oct 2026 21:36:18 +0200 Subject: [PATCH] Add xidle-dim notifier to dim the screen before xss-lock locks Fades a black window in over the screen through the compositor, so it works with redshift (no gamma changes); does nothing without a compositor. README shows it in the xss-lock setup. --- README.md | 22 +++++++-- pyproject.toml | 3 +- xidle-dim | 119 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100755 xidle-dim diff --git a/README.md b/README.md index 9f23c69..727fe10 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Formerly `xautolock-inhibit`, which supported only xautolock. - `xautolock` or `xss-lock`, already running in the session - `python`, `python-gobject` - `libx11`, `libxss` +- For `xidle-dim`: a compositor, such as picom ## Installation @@ -46,14 +47,16 @@ spawnOnce "xautolock -time 10 -locker slock -detectsleep" spawnOnce "systemctl --user start xidle-inhibit" ``` -With xss-lock, the lock timeout is the X screensaver timeout (`xset s`): +With xss-lock, the lock timeout is the X screensaver timeout (`xset s TIMEOUT CYCLE`). This dims the screen with `xidle-dim` at 9:50 and locks at 10:00: ```haskell -spawnOnce "xset s 600" -spawnOnce "xss-lock -- slock" +spawnOnce "xset s 590 10" +spawnOnce "xss-lock -n xidle-dim -- slock" spawnOnce "systemctl --user start xidle-inhibit" ``` +Without dimming, use `xset s 600 0` and `xss-lock -- slock`. Start xss-lock from the window manager, not from a terminal: terminals like kitty run commands outside the login session, and xss-lock then ignores `loginctl lock-session`. + The unit has no `[Install]` section on purpose. Enabling it at login would also start it in a desktop environment, or before X is up; starting it from the window manager limits it to that session. The user service manager needs `DISPLAY` (and `XAUTHORITY`) in its environment, which `/etc/X11/xinit/xinitrc.d/50-systemd-user.sh` sets up for `startx` sessions. ### Upgrading from xautolock-inhibit @@ -107,6 +110,19 @@ Every request, release and locker command is logged to the journal: journalctl --user -u xidle-inhibit ``` +### Dimming before the lock + +`xidle-dim` is an xss-lock notifier (`-n`). It fades a black window over the screen until xss-lock stops it, on user activity or when the locker starts. It needs a compositor such as picom and does nothing without one. Because it doesn't change gamma, it works alongside redshift. + +``` +xidle-dim [--opacity 0-1] [--fade SECONDS] +``` + +- `--opacity`: final darkness (default 0.6). +- `--fade`: fade-in time (default 1.5). + +Options go inside the notifier command: `xss-lock -n "xidle-dim --opacity 0.8" -- slock`. + ## App notes - **Sandboxed apps** may be blocked from reaching the service. For a firejail profile that filters D-Bus (`dbus-user filter`, as the Firefox-based profiles do), allow it in the app's `.local` file, e.g. `zen-browser.local`: diff --git a/pyproject.toml b/pyproject.toml index 4e6c1ec..561ad3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [tool.ruff] line-length = 120 -extend-include = ["xidle-inhibit"] +extend-include = ["xidle-inhibit", "xidle-dim"] [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP"] [tool.mypy] +# Scripts both map to __main__, so check xidle-dim separately: mypy xidle-dim files = ["xidle-inhibit"] strict = true diff --git a/xidle-dim b/xidle-dim new file mode 100755 index 0000000..dad665f --- /dev/null +++ b/xidle-dim @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Dim the screen until killed, as an xss-lock notifier: xss-lock -n xidle-dim -- slock. + +Maps a black window over the whole screen and fades in its opacity through the compositor; gamma is left alone, +so it doesn't clash with redshift. Without a compositor it does nothing, as the window would be solid black. +""" + +import argparse +import ctypes +import signal +import sys +import time +from types import FrameType + +CW_BACK_PIXEL = 1 << 1 +CW_OVERRIDE_REDIRECT = 1 << 9 +INPUT_OUTPUT = 1 +XA_CARDINAL = 6 +PROP_MODE_REPLACE = 0 +OPACITY_MAX = 0xFFFFFFFF + + +class XSetWindowAttributes(ctypes.Structure): + _fields_ = [ + ("background_pixmap", ctypes.c_ulong), + ("background_pixel", ctypes.c_ulong), + ("border_pixmap", ctypes.c_ulong), + ("border_pixel", ctypes.c_ulong), + ("bit_gravity", ctypes.c_int), + ("win_gravity", ctypes.c_int), + ("backing_store", ctypes.c_int), + ("backing_planes", ctypes.c_ulong), + ("backing_pixel", ctypes.c_ulong), + ("save_under", ctypes.c_int), + ("event_mask", ctypes.c_long), + ("do_not_propagate_mask", ctypes.c_long), + ("override_redirect", ctypes.c_int), + ("colormap", ctypes.c_ulong), + ("cursor", ctypes.c_ulong), + ] + + +def load_x11() -> ctypes.CDLL: + x11 = ctypes.CDLL("libX11.so.6") + p, ul, i = ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = p + for name in ("XDefaultScreen",): + getattr(x11, name).argtypes = [p] + for name in ("XRootWindow", "XBlackPixel"): + getattr(x11, name).argtypes = [p, i] + getattr(x11, name).restype = ul + for name in ("XDisplayWidth", "XDisplayHeight"): + getattr(x11, name).argtypes = [p, i] + x11.XInternAtom.argtypes = [p, ctypes.c_char_p, i] + x11.XInternAtom.restype = ul + x11.XGetSelectionOwner.argtypes = [p, ul] + x11.XGetSelectionOwner.restype = ul + x11.XCreateWindow.argtypes = [p, ul, i, i, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, i, ctypes.c_uint, p, ul, + ctypes.POINTER(XSetWindowAttributes)] + x11.XCreateWindow.restype = ul + x11.XChangeProperty.argtypes = [p, ul, ul, ul, i, i, p, i] + x11.XMapRaised.argtypes = [p, ul] + x11.XFlush.argtypes = [p] + return x11 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--opacity", type=float, default=0.6, help="final darkness, 0 to 1 (default: %(default)s)") + parser.add_argument("--fade", type=float, default=1.5, help="fade-in time in seconds (default: %(default)s)") + args = parser.parse_args() + if not 0 <= args.opacity <= 1 or args.fade < 0: + parser.error("--opacity must be between 0 and 1 and --fade not negative") + return args + + +def exit_on_signal(_signum: int, _frame: FrameType | None) -> None: + sys.exit(0) # the X server removes the window when the connection closes + + +def main() -> None: + args = parse_args() + # xss-lock sends SIGHUP on user activity and SIGTERM when the locker starts. + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): + signal.signal(sig, exit_on_signal) + + x11 = load_x11() + dpy = x11.XOpenDisplay(None) + if not dpy: + sys.exit("xidle-dim: cannot open display") + screen = x11.XDefaultScreen(dpy) + if not x11.XGetSelectionOwner(dpy, x11.XInternAtom(dpy, f"_NET_WM_CM_S{screen}".encode(), False)): + print("xidle-dim: no compositor running, not dimming", file=sys.stderr) + signal.pause() # stay alive like a notifier should, until xss-lock kills us + + attrs = XSetWindowAttributes(background_pixel=x11.XBlackPixel(dpy, screen), override_redirect=True) + win = x11.XCreateWindow(dpy, x11.XRootWindow(dpy, screen), 0, 0, x11.XDisplayWidth(dpy, screen), + x11.XDisplayHeight(dpy, screen), 0, 0, INPUT_OUTPUT, None, + CW_BACK_PIXEL | CW_OVERRIDE_REDIRECT, ctypes.byref(attrs)) + opacity_atom = x11.XInternAtom(dpy, b"_NET_WM_WINDOW_OPACITY", False) + + def set_opacity(level: float) -> None: + value = ctypes.c_ulong(int(level * OPACITY_MAX)) # format 32 properties are passed as longs + x11.XChangeProperty(dpy, win, opacity_atom, XA_CARDINAL, 32, PROP_MODE_REPLACE, ctypes.byref(value), 1) + x11.XFlush(dpy) + + set_opacity(0) + x11.XMapRaised(dpy, win) + steps = max(1, int(args.fade * 30)) + for n in range(1, steps + 1): + set_opacity(args.opacity * n / steps) + time.sleep(args.fade / steps) + while True: + signal.pause() + + +if __name__ == "__main__": + main()