Feature Request: Accessibility Grayscale/Monochrome Toggle
Feature Request: Accessibility Grayscale/Monochrome Toggle
Summary
Please add a system-wide grayscale (monochrome) toggle to the accessibility settings. This is a standard accessibility feature on every other major operating system and is conspicuously absent from Linux desktop environments despite the underlying infrastructure being fully available.
Precedent on other platforms
| Platform | Location |
|---|---|
| macOS | System Settings → Accessibility → Display → Colour Filters |
| Windows | Settings → Accessibility → Colour Filters |
| iOS | Settings → Accessibility → Display & Text Size → Colour Filters |
| Android | Settings → Accessibility → Colour Correction |
All of these support a one-tap toggle and most support a shortcut gesture or keyboard shortcut to switch quickly.
Who needs this
- People with visual processing differences
- People with colour blindness (grayscale can reduce visual noise)
- People with migraines or eye fatigue
- People who want to reduce visual stimulation at night
- Focus/productivity users (grayscale is used intentionally to reduce phone/screen addiction)
Why this is not a niche request
This feature has been requested repeatedly across GNOME, KDE, and XFCE issue trackers for over a decade. It remains unimplemented despite being achievable with a small amount of code.
The infrastructure already exists
The X11 CTM (Colour Transformation Matrix) RandR property is already exposed on modern drivers and can apply a grayscale matrix directly at the display output level — affecting all monitors simultaneously, regardless of compositor or window manager. This works below the DE level, meaning a single implementation would work across all X11 desktops.
The following Python script demonstrates a working implementation (tested on X11 with the NVIDIA proprietary driver, but the RandR CTM property is driver/DE-agnostic):
`#!/usr/bin/env python3 import ctypes, sys
GRAY = [0.2126,0.7152,0.0722, 0.2126,0.7152,0.0722, 0.2126,0.7152,0.0722] COLOR = [1.0,0.0,0.0, 0.0,1.0,0.0, 0.0,0.0,1.0]
def make_ctm(matrix): words = [] for f in matrix: v = round(f * (1 << 32)) if v < 0: v += (1 << 64) words.append(v & 0xFFFFFFFF) words.append((v >> 32) & 0xFFFFFFFF) arr = (ctypes.c_ulong * 18)(*words) return arr
ul = ctypes.c_ulong vp = ctypes.c_void_p up = ctypes.POINTER(ul)
class ScreenRes(ctypes.Structure): fields = [('timestamp',ul),('configTimestamp',ul), ('ncrtc',ctypes.c_int),('crtcs',up), ('noutput',ctypes.c_int),('outputs',up), ('nmode',ctypes.c_int),('modes',vp)]
class OutputInfo(ctypes.Structure): fields = [('timestamp',ul),('crtc',ul), ('name',ctypes.c_char_p),('nameLen',ctypes.c_int), ('mm_width',ul),('mm_height',ul), ('connection',ctypes.c_ushort),('subpixel_order',ctypes.c_ushort), ('ncrtc',ctypes.c_int),('crtcs',up), ('nclone',ctypes.c_int),('clones',up), ('nmode',ctypes.c_int),('npreferred',ctypes.c_int),('modes',up)]
X = ctypes.CDLL('libX11.so.6') Xrr = ctypes.CDLL('libXrandr.so.2')
X.XOpenDisplay.restype = vp; X.XOpenDisplay.argtypes = [ctypes.c_char_p] X.XCloseDisplay.argtypes = [vp] X.XDefaultRootWindow.restype = ul; X.XDefaultRootWindow.argtypes = [vp] X.XInternAtom.restype = ul; X.XInternAtom.argtypes = [vp, ctypes.c_char_p, ctypes.c_int] X.XFlush.argtypes = [vp]
Xrr.XRRGetScreenResources.restype = ctypes.POINTER(ScreenRes) Xrr.XRRGetScreenResources.argtypes = [vp, ul] Xrr.XRRFreeScreenResources.argtypes = [ctypes.POINTER(ScreenRes)] Xrr.XRRGetOutputInfo.restype = ctypes.POINTER(OutputInfo) Xrr.XRRGetOutputInfo.argtypes = [vp, ctypes.POINTER(ScreenRes), ul] Xrr.XRRFreeOutputInfo.argtypes = [ctypes.POINTER(OutputInfo)] Xrr.XRRChangeOutputProperty.argtypes = [vp, ul, ul, ul, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int]
mode = sys.argv[1] if len(sys.argv) > 1 else 'color' ctm = make_ctm(GRAY if mode == 'gray' else COLOR)
dpy = X.XOpenDisplay(None) if not dpy: sys.exit("Cannot open display")
root = X.XDefaultRootWindow(dpy) ctm_atom = X.XInternAtom(dpy, b'CTM', 0) res = Xrr.XRRGetScreenResources(dpy, root)
for i in range(res.contents.noutput): oid = res.contents.outputs[i] info = Xrr.XRRGetOutputInfo(dpy, res, oid) if not info: continue connected = info.contents.connection == 0 name = info.contents.name.decode() if info.contents.name else '?' Xrr.XRRFreeOutputInfo(info) if not connected: continue Xrr.XRRChangeOutputProperty(dpy, oid, ctm_atom, 19, 32, 0, ctm, 18) print(f" {name}: {mode}")
Xrr.XRRFreeScreenResources(res) X.XFlush(dpy) X.XCloseDisplay(dpy) `
Usage: python3 set-ctm.py gray / python3 set-ctm.py color
This correctly applies ITU-R BT.709 luminance coefficients across all connected outputs simultaneously, including multi-monitor setups, and survives display hotplugging when wrapped in a toggle script.
Notes on the implementation journey
Getting here required significant trial and error. Approaches that do NOT work reliably:
-
picom shaders — the shader API changed significantly across versions; GLSL version handling is inconsistent; doesn't work with
xrenderbackend at all -
xrandr --set CTM "..."(string form) — xrandr CLI cannot parse CTM as a string; it is a binary blob property -
xcalib -co 0— contrast range is 1–100, zero is rejected; not a grayscale solution -
xgamma— only affects one output; cannot mix channels for true luminance
The CTM binary blob approach via libXrandr ctypes is the correct solution. It requires knowing that on 64-bit Linux, each of the 9 matrix values is stored as two 32-bit words (lo/hi of a 64-bit fixed-point s31.32 value), giving 18 × 32-bit items total, with X11 property type 19.
Request
Please implement a grayscale/colour filter toggle in the accessibility settings panel, exposed as:
- A toggle switch in Settings → Accessibility
- An optional keyboard shortcut binding
- Persistence across sessions
The hard part is already solved. This would be a meaningful quality-of-life and accessibility improvement for a large number of users.