Sign in before continuing.
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 `xrender` backend 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:
1. A toggle switch in Settings → Accessibility
2. An optional keyboard shortcut binding
3. 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.
issue