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.

Assignee Loading
Time tracking Loading