2025, Nov 18 15:00

Display a 16x16 PNG icon in an X11 window with python-xlib and Pillow: avoid X.Image, use put_pil_image

Learn how to render a 16x16 PNG in an X11 window using python-xlib and Pillow. Fix the X.Image AttributeError, use screen.root_visual, and draw with put_pil_image in a concise working example

Rendering tiny window icons in a Python-based X11 prototype sounds easy until you meet the moving target of what a given binding actually exposes. If you tried to construct an XImage directly and push raw bytes through python-xlib, you probably ran into an exception like “module 'Xlib.X' has no attribute 'Image'”. Below is a compact walkthrough of the underlying cause and a practical path that works with Pillow and python-xlib.

Problem setup

The goal is to show a 16×16 PNG inside an X11 window created from Python 3 using Pillow and python-xlib. A straightforward approach is to open the PNG with PIL, convert it to RGB bytes, build an XImage, and send it to the window with put_image. That’s where things fall apart.

Failing example

The following minimal program initializes the display, creates a window, converts a PNG into raw bytes, and then attempts to create an X.Image to blit it. It also auto-generates a red 16×16 test image on first run.

from Xlib import display, X  # Xutil is not required here
from PIL import Image, ImageDraw
import os
def draw_icon(img_path, win_w, win_h, pos_x=50, pos_y=50):
    pil_img = Image.open(img_path)
    if pil_img.mode != 'RGB':
        pil_img = pil_img.convert('RGB')
    rgb_buf = pil_img.tobytes("raw", "RGB")
    dpy = display.Display()
    scr = dpy.screen()
    root_win = scr.root
    vis = scr.root_visual  # previously tried "default_visual"
    depth_bits = scr.root_depth
    wnd = root_win.create_window(
        pos_x, pos_y,
        win_w, win_h,
        1,
        depth_bits,
        X.InputOutput,
        vis,
        background_pixel=scr.white_pixel,
        event_mask=X.ExposureMask | X.KeyPressMask | X.StructureNotifyMask
    )
    wnd.set_wm_name("PNG image")
    wnd.map()
    gctx = wnd.create_gc(
        foreground=scr.black_pixel,
        background=scr.white_pixel
    )
    # This is where it blows up: X.Image doesn't exist in python-xlib
    ximg = X.Image(
        data=rgb_buf,
        width=16,
        height=16,
        depth=depth_bits,
        bitmap_unit=8,
        bitmap_pad=32,
        byte_order=X.LSBFirst,
        bitmap_bit_order=X.LSBFirst,
        format=X.ZPixmap,
        bytes_per_line=16 * 3
    )
    while True:
        evt = dpy.next_event()
        if evt.type == X.Expose:
            if evt.count == 0:
                wnd.put_image(gctx, ximg, 0, 0, 0, 0, 16, 16)
                dpy.flush()
        elif evt.type == X.KeyPress:
            break
        elif evt.type == X.ConfigureNotify:
            pass
    wnd.destroy()
    dpy.close()
if __name__ == '__main__':
    sample = "dummy.png"
    if not os.path.exists(sample):
        tmp = Image.new('RGB', (16, 16), color='red')
        painter = ImageDraw.Draw(tmp)
        painter.text((1, 1), "abc", fill=(255, 255, 255))
        tmp.save(sample)
    draw_icon(sample, 200, 200, 100, 100)

Running this yields:

AttributeError: module 'Xlib.X' has no attribute 'Image'

What’s actually wrong

There are two separate issues. First, one might search for default_visual on the screen object; it’s not there, while root_visual is. Second, X.Image is not provided by python-xlib. Instead of constructing an XImage yourself, there is a built-in helper that takes a PIL.Image directly and handles the transfer for you.

The fix

Use screen.root_visual instead of a missing default_visual, and replace the manual XImage path with the window’s put_pil_image method. This removes the need to convert to raw bytes and avoids the absent X.Image entirely.

from Xlib import display, X
from PIL import Image, ImageDraw
import os
def blit_png_to_window(img_path, win_w, win_h, pos_x=50, pos_y=50):
    pil_img = Image.open(img_path)
    if pil_img.mode != 'RGB':
        pil_img = pil_img.convert('RGB')
    dpy = display.Display()
    scr = dpy.screen()
    root_win = scr.root
    vis = scr.root_visual
    depth_bits = scr.root_depth
    wnd = root_win.create_window(
        pos_x, pos_y,
        win_w, win_h,
        1,
        depth_bits,
        X.InputOutput,
        vis,
        background_pixel=scr.white_pixel,
        event_mask=X.ExposureMask | X.KeyPressMask | X.StructureNotifyMask
    )
    wnd.set_wm_name("PNG preview")
    wnd.map()
    gctx = wnd.create_gc(
        foreground=scr.black_pixel,
        background=scr.white_pixel
    )
    while True:
        evt = dpy.next_event()
        if evt.type == X.Expose:
            if evt.count == 0:
                wnd.put_pil_image(gctx, 0, 0, pil_img)
                dpy.flush()
        elif evt.type == X.KeyPress:
            break
        elif evt.type == X.ConfigureNotify:
            pass
    wnd.destroy()
    dpy.close()
if __name__ == '__main__':
    sample = "dummy.png"
    if not os.path.exists(sample):
        tmp = Image.new('RGB', (16, 16), color='red')
        painter = ImageDraw.Draw(tmp)
        painter.text((1, 1), "abc", fill=(255, 255, 255))
        tmp.save(sample)
    blit_png_to_window(sample, 200, 200, 100, 100)

Why this matters

When you explore a low-level API via a Python binding, not every native concept is surfaced one-to-one. Trying to force an unavailable X.Image path results in dead ends, while leaning on an existing helper like put_pil_image yields a working pipeline with less code and fewer footguns. If you’re experimenting with a Python window manager, knowing which primitives the binding exposes will save hours of trial and error. There are also projects in the Python ecosystem that take different routes; for example, one window manager uses xcffib on top of XCB.

Practical advice

If you’re unsure whether a field exists on python-xlib objects, introspection helps. Checking attributes with dir(...) or inspecting available keys in an object’s internal data can point you to what's actually present, such as finding root_visual instead of expecting default_visual. For pushing pixel data, prefer the binding’s direct integration with Pillow rather than hand-rolling XImage structures that aren’t available.

Wrap-up

To render 16×16 PNG icons in an X11 window via python-xlib, avoid non-existent constructs like X.Image and rely on window.put_pil_image with a PIL.Image. Use screen.root_visual where applicable, and keep the event loop minimal, updating on Expose and exiting on KeyPress. This keeps the code concise, robust, and aligned with what the library offers today.