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.
This commit is contained in:
Daniele Fucini
2026-10-23 21:36:18 +02:00
parent 6548bbb232
commit c5f2ac5fe0
3 changed files with 140 additions and 4 deletions
+19 -3
View File
@@ -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`:
+2 -1
View File
@@ -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
Executable
+119
View File
@@ -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()