Display System
🖥️ Part 1 — Display Servers / Display Protocols
These are the low-level systems responsible for drawing pixels on screen, handling input devices (keyboard, mouse, touch), and communicating with GPU drivers. They are the layer below desktop environments and window managers.
🔷 X11 / X.Org Server
The older and historically dominant display server for Linux/Unix-like systems.
Core Concepts
| Feature | Description |
|---|---|
| Protocol | X11 (1987) — networked GUI protocol |
| Server Name | X.Org Server (most common implementation) |
| Architecture | Central server handles outputs, clients draw windows |
| Extensions | Needed for modern features (compositing, HiDPI, scaling) |
Key Characteristics
✔ Mature, stable, compatible with decades of software ✔ Remote network rendering works very well (e.g., ssh -X, X forwarding) ✔ Extremely configurable, many window managers (i3, Awesome, bspwm)
✘ Performance overhead — multiple round-trips between client & server ✘ Security limitations — apps can spy on each other’s input events ✘ Extensions (XRandR, XRender, XInput2) bolted on rather than native
Why it still exists: compatibility and tooling. Some legacy apps require it.
🔷 Wayland
The modern successor to X11, built to fix X11’s inherent design issues.
Core Ideas
| Feature | X11 | Wayland |
|---|---|---|
| Design | Everything goes through one server | Compositors manage their own windows |
| Rendering | Server manages drawing | Clients draw directly → better latency |
| Security | Weak sandboxing | Strong isolation, apps can’t snoop |
| Remote access | Built-in? No | Under development via PipeWire & RDP |
| Extensions | Many | Protocols modular + evolving |
Benefits
✔ Lower input latency → faster responsiveness ✔ Better power efficiency (especially on laptops) ✔ Fractional scaling and HiDPI are cleaner ✔ No screen tearing when compositor is native Wayland
Downsides (as of now)
⚠ Older apps may need XWayland (compatibility layer) ⚠ Screen capture + remote desktop required PipeWire & new APIs ⚠ Niche color-critical workflows still maturing
Real-world adoption status
| Desktop | Wayland Ready? |
|---|---|
| GNOME | Excellent — Wayland by default on most distros |
| KDE Plasma | Very good — Wayland default in Plasma 6 |
| XFCE | Improving, still experimental for some features |
| Ubuntu | Wayland default since 22.04 (for GNOME) |
🧠 How the stack layers together
Hardware (GPU, Monitor)
↓
Kernel Drivers (DRM/KMS)
↓
Display Server / Compositor
X11 (Xorg) OR Wayland (Mutter, KWin, etc.)
↓
Window Manager
↓
Desktop Environment (GNOME/KDE/XFCE etc.)
↓
Applications
🖥️ Part 2 — Desktop Environments (DEs)
A Desktop Environment = shell + window manager + settings + core apps You can think of DEs as “the full user experience layer”.
X11/Wayland = backend | DE = frontend UI
🔷 GNOME
Default on Ubuntu. Minimal, touch-friendly, modern.
Philosophy
- Less UI, more focus
- Workspaces + Activities Overview instead of desktop icons
- No heavy customization → consistent UX
Tech Stack
| Component | Tech |
|---|---|
| Window Manager / Compositor | Mutter |
| Toolkit | GTK (C / GNOME Libs) |
| Extensions | GNOME Shell Extensions (.js based) |
Strengths
✔ Clean, simple UI ✔ Great Wayland support ✔ Good accessibility + HiDPI scaling ✔ Huge ecosystem of GTK apps
Weaknesses
✘ Limited configurability without extensions ✘ Higher RAM usage than XFCE/Openbox ✘ Some dislike the workflow vs Windows-style taskbar
Best for
- Productivity-focused workflow
- Smooth Wayland experience
- Ubuntu users who want zero-config elegance
🔷 KDE Plasma
Often considered the most customizable DE in Linux history.
Philosophy
- Freedom + configurability
- Traditional desktop metaphor
- Aim: Lightweight but powerful
Tech Stack
| Component | Tech |
|---|---|
| Window Manager / Compositor | KWin |
| Toolkit | Qt |
| Framework | KDE Frameworks (KF5 → KF6) |
Strengths
✔ Ridiculously customizable (themes, panels, animations) ✔ Plasma 6: efficient, fast, excellent Wayland support ✔ KDE Apps: Dolphin, Kate, Okular, Kdenlive, Gwenview ✔ Works well even on old hardware
Weaknesses
✘ Can overwhelm new users ✘ Many settings means more surface for breakage ✘ Qt apps sometimes look odd in GTK environments
Best for
- Power users
- Folks coming from Windows
- Tinkerers who want full control
🔷 XFCE
Lightweight, traditional, stable for decades.
Philosophy
- Fast, light, classic desktop layout
- Minimal resource usage
- Slow + careful development (rock solid)
Strengths
✔ Extremely lightweight (great for older laptops) ✔ Simple & predictable UI ✔ Highly stable over long periods ✔ Runs great under X11, improving Wayland support
Weaknesses
✘ Visuals more dated unless themed ✘ Less modern UX vs GNOME/KDE ✘ Wayland still catching up
Best for
- Older machines or low-power laptops
- Users wanting speed + simplicity
Other notable DEs (quick overview)
| DE | Highlights | Best For |
|---|---|---|
| Cinnamon | Linux Mint default, Windows-like UI | Windows converts |
| MATE | GNOME 2 fork, light & stable | Traditional desktop lovers |
| LXQt | Lighter than XFCE, Qt-based | Very low-resource hardware |
| Budgie | Sleek GNOME-based shell | Modern aesthetics without clutter |
| Pantheon | macOS-like (elementaryOS) | Elegant design focus |
🧭 Recommendations for You (Ubuntu user)
If you want default experience
→ Stay with GNOME on Wayland
If you want more speed + performance
sudo apt install xfce4
If you want full customization power
sudo apt install kde-standard
If apps misbehave under Wayland
Choose X11 session at login screen (gear icon) as fallback.
Quick Summary Matrix
| Feature | X11 | Wayland | GNOME | KDE Plasma | XFCE |
|---|---|---|---|---|---|
| Age | Very old | Modern | Modern | Modern/Advanced | Classic |
| Performance | Good but inefficient | Faster, lower latency | Smooth | Very smooth | Efficient |
| Customizable | Depends on WM | Depends on compositor | Limited | Extremely flexible | Moderate |
| RAM usage | Medium | Low-Medium | Higher | Medium-Low | Very low |
| Best for | Legacy + remote display | Future of Linux | Simple workflow | Power users | Lightweight setups |
🛠️ Part 3 - Multi-Monitor Troubleshooting (GNOME on Wayland, NVIDIA)
A runbook from a real three-monitor desktop (Ubuntu, GNOME 50 on Wayland, GTX 1660 SUPER, NVIDIA open driver 595) where two of the three monitors were the same model. Every symptom first looked like one problem and turned out to be another, so it is organised by symptom.
How GNOME tells monitors apart
Mutter, GNOME’s compositor, saves layouts in ~/.config/monitors.xml and identifies each monitor by connector + vendor + product + serial, all but the connector read from the monitor’s EDID.
# What each port reports (no root needed)
for c in /sys/class/drm/card*-*; do
echo "$(basename $c): $(cat $c/status)"; strings $c/edid 2>/dev/null | head -2
done
# What Mutter sees, as (connector, vendor, product, serial)
gdbus call --session --dest org.gnome.Mutter.DisplayConfig \
--object-path /org/gnome/Mutter/DisplayConfig \
--method org.gnome.Mutter.DisplayConfig.GetCurrentStateTwo monitors of the same model often report the same serial; cheap panels ship 0000000000000. The connector is then the only thing separating them, which has knock-on effects:
- Swapping their cables swaps their saved positions.
monitors.xmlkeeps a layout for every cabling it has ever seen, and X11 and Wayland name the same port differently (HDMI-0vsHDMI-1). Sixteen stale entries had built up here, some describing the twins on ports they no longer used.- Other tools get confused too.
ddcutil detectlabelled both twinscard1-DP-1: the NVIDIA driver does not expose which I2C bus belongs to which port (/sys/class/drm/card*-*/ddcis empty), so ddcutil matches EDIDs, and identical EDIDs both match the first port.
Moving both twins onto DisplayPort does not fix any of this. It only removes HDMI from the picture.
Symptom 1: one screen stays black after lock and unlock
Super+L blanks the monitors (DPMS off). On unlock GNOME only powers the outputs back on; it does not redo a full modeset, because as far as it knows nothing changed. A monitor that fails to resync stays black while the system insists it is on:
for c in /sys/class/drm/card*-*; do
echo "$(basename $c) dpms=$(cat $c/dpms) enabled=$(cat $c/enabled)"
donedpms=On enabled=enabled on a black screen means it is this problem and not a layout problem. Nothing is logged by the kernel or gnome-shell.
The tell-tale: changing the refresh rate in Settings brings it back. Any mode change forces a full modeset, and the monitor relocks onto the signal. The prime suspect is 4K at 60 Hz over HDMI on NVIDIA, which needs HDMI 2.0 scrambling renegotiated after the monitor sleeps; DisplayPort does not need that handshake.
The same flip can be scripted against Mutter’s D-Bus API. monitors-wake switches each monitor to another refresh rate at the same resolution, waits, then restores the original layout:
#!/usr/bin/python3 -I
"""Wake a monitor that stayed black after unlock by forcing a full modeset.
monitors-wake # cycle every monitor
monitors-wake HDMI-1 DP-3 # cycle only these connectors
monitors-wake --dry-run # validate with Mutter, change nothing
"""
import argparse
import sys
import time
from gi.repository import Gio, GLib
BUS_NAME = "org.gnome.Mutter.DisplayConfig"
OBJ_PATH = "/org/gnome/Mutter/DisplayConfig"
METHOD_VERIFY, METHOD_TEMPORARY = 0, 1
def proxy():
return Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, BUS_NAME, OBJ_PATH, BUS_NAME, None
)
def current_state(p):
serial, monitors, logical, props = p.call_sync(
"GetCurrentState", None, Gio.DBusCallFlags.NONE, -1, None
).unpack()
return serial, monitors, logical, props
def current_modes(monitors):
"""connector -> (current mode tuple, list of all mode tuples)."""
out = {}
for spec, modes, _props in monitors:
cur = next((m for m in modes if m[6].get("is-current")), None)
if cur is not None:
out[spec[0]] = (cur, modes)
return out
def alternate_mode(cur, modes):
"""Same resolution, closest different refresh rate."""
_id, w, h, rate = cur[:4]
same_res = [m for m in modes if m[1] == w and m[2] == h and abs(m[3] - rate) > 0.01]
return min(same_res, key=lambda m: abs(m[3] - rate)) if same_res else None
def build_config(logical, mode_for):
"""Rebuild the logical layout, picking each connector's mode via mode_for."""
cfg = []
for x, y, scale, transform, primary, specs, _props in logical:
mons = [(s[0], mode_for(s[0]), {}) for s in specs]
cfg.append((x, y, scale, transform, primary, mons))
return cfg
def apply(p, serial, method, cfg):
p.call_sync(
"ApplyMonitorsConfig",
GLib.Variant("(uua(iiduba(ssa{sv}))a{sv})", (serial, method, cfg, {})),
Gio.DBusCallFlags.NONE,
-1,
None,
)
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("connectors", nargs="*", help="connectors to cycle (default: all)")
ap.add_argument("--dry-run", action="store_true", help="ask Mutter to verify only")
ap.add_argument("--pause", type=float, default=2.0, help="seconds on the alternate rate")
args = ap.parse_args()
p = proxy()
serial, monitors, logical, _ = current_state(p)
modes = current_modes(monitors)
original = {c: cur[0] for c, (cur, _) in modes.items()}
targets = args.connectors or list(modes)
unknown = [c for c in targets if c not in modes]
if unknown:
sys.exit(f"unknown connector(s): {', '.join(unknown)}; active: {', '.join(modes)}")
swapped = dict(original)
for c in targets:
cur, all_modes = modes[c]
alt = alternate_mode(cur, all_modes)
if alt is None:
print(f"{c}: no other refresh rate at {cur[1]}x{cur[2]}, leaving it alone")
continue
swapped[c] = alt[0]
print(f"{c}: {cur[3]:.3f} Hz -> {alt[3]:.3f} Hz -> back")
if swapped == original:
sys.exit("nothing to cycle")
method = METHOD_VERIFY if args.dry_run else METHOD_TEMPORARY
apply(p, serial, method, build_config(logical, swapped.get))
if args.dry_run:
apply(p, serial, METHOD_VERIFY, build_config(logical, original.get))
print("dry run: Mutter accepted both configurations, nothing changed")
return
time.sleep(args.pause)
# Every apply bumps the serial, so fetch it again before restoring.
serial, _, logical, _ = current_state(p)
apply(p, serial, METHOD_TEMPORARY, build_config(logical, original.get))
print("restored original layout")
if __name__ == "__main__":
try:
main()
except GLib.Error as e:
sys.exit(f"Mutter refused: {e.message}")Save it as ~/.local/bin/monitors-wake and chmod +x it. Three details matter:
- The shebang is
/usr/bin/python3, notpython3. PyGObject (gi) comes from the apt packagepython3-giand exists only in the system interpreter; a project venv onPATHdoes not have it. ApplyMonitorsConfigtakes a method.0(verify) makes Mutter validate a config without applying it, which is how--dry-runtests the script without the screens flickering.1(temporary) applies without writingmonitors.xmland without the “Keep changes?” dialog.2(persistent) saves.- The serial changes on every apply, so it is fetched again before restoring; reusing the old one is rejected.
Symptom 2: the monitor is connected but GNOME leaves it off
sysfs says status=connected, and Mutter lists the monitor with its modes in GetCurrentState, but no logical monitor uses it. Here that happened right after re-cabling the twins: GNOME saved a two-monitor layout and left the third out. It is a GNOME layout decision, not hardware.
Confirm by asking Mutter to verify a config that includes it (method 0). If Mutter accepts, apply it temporarily (method 1), then arrange the screens in Settings -> Displays -> Apply -> Keep Changes so the layout is saved.
Symptom 3: enabled everywhere, still no picture
GNOME, the driver and sysfs all say the output is on, and dropping it to 1920x1080 (a quarter of the 4K bandwidth) is still blank. The picture is leaving the card and the monitor is not showing it.
Check the monitor’s own input source before blaming the cable or port. Many monitors do not switch inputs automatically. One moved from HDMI to DisplayPort keeps listening on HDMI, and shows nothing however the computer drives it. That was the whole cause here, and the fix was one button press on the monitor’s menu.
The clue is DDC/CI. The card can still read the EDID over the DisplayPort cable (that is how it knew the model), but DDC commands to the monitor fail:
sudo apt install ddcutil && sudo modprobe i2c-dev
sudo ddcutil detect # the dead one: "DDC communication failed"
sudo ddcutil --bus 6 getvcp 60 # input source of the working twin
sudo ddcutil --bus 3 setvcp 60 0x08 # try to switch the dead one; fails if it is not listening- Run these from GNOME Terminal.
sudodoes not work from a VS Code integrated terminal, which runs withno_new_privs. - Address twins by
--bus N, not--display N, since ddcutil may have mislabelled them. - VCP
0x60input codes are vendor-specific in practice. The MCCS standard gives DisplayPort-1 as0x0f, but this Kogan model reports0x08for DisplayPort, which ddcutil prints as “S-Video-2”. Read the code from a working twin rather than trusting the table. - A monitor that ignores DDC cannot be switched over DDC either. Use its on-screen menu buttons.
Elimination order
| Step | Check | What a pass rules out |
|---|---|---|
| 1 | cat /sys/class/drm/card*-*/status says connected |
Cable unplugged, monitor unpowered |
| 2 | Mutter’s GetCurrentState puts the connector in a logical monitor |
GNOME leaving it out of the layout (Symptom 2) |
| 3 | dpms=On enabled=enabled in sysfs |
The output being switched off |
| 4 | Still blank at 1920x1080 | A cable or port that cannot carry 4K at 60 Hz |
| 5 | ddcutil gets an answer from it |
The monitor listening on another input (Symptom 3) |
| 6 | Swap cables with a working twin | Isolates the cable, the card’s port, or the monitor’s input |
If the screen comes back after a refresh-rate change, skip the table: it is Symptom 1.
Clearing stale layouts
cp ~/.config/monitors.xml ~/.config/monitors.xml.bak-$(date +%F)
rm ~/.config/monitors.xml
# log out and back in, then arrange the screens once in Settings -> DisplaysLog out straight after deleting. While the session runs, Mutter holds every stored layout in memory and writes them all back on the next save.