Getting notified about window title changes

window-managerxorg

…without polling.

I want to detect when the currently focused window changes so that I can update a piece of custom GUI in my system.

Points of interests:

  • real time notifications. Having 0.2s lag is okay, having 1s lag is meh, having 5s lag is totally unacceptable.
  • resource friendliness: for this reason, I want to avoid polling. Running xdotool getactivewindow getwindowname every, say, half a second, works quite alright… but is spawning 2 processes a second all that friendly to my system?

In bspwm, one can use bspc subscribe which prints a line with some (very) basic stats, every time window focus changes. This approach seems nice at first, but listening to this won't detect when window title changes by itself (for example, changing tabs in the web browser will go unnoticed this way.)

So, is spawning new process every half a second okay on Linux, and if not, how can I do things better?

One thing that comes to my mind is to try to emulate what window managers do. But can I write hooks for events such as "window creation", "title change request" etc. independently from the working window manager, or do I need to become a window manager itself? Do I need root for this?

(Another thing that came to my mind is to look at xdotool's code and emulate only the things that interest me so that I can avoid all the process spawning boilerplate, but it still would be polling.)

Best Answer

I couldn't get your focus-change approach to work reliably under Kwin 4.x, but modern window managers maintain a _NET_ACTIVE_WINDOW property on the root window that you can listen for changes to.

Here's a Python implementation of just that:

#!/usr/bin/python
from contextlib import contextmanager
import Xlib
import Xlib.display

disp = Xlib.display.Display()
root = disp.screen().root

NET_ACTIVE_WINDOW = disp.intern_atom('_NET_ACTIVE_WINDOW')
NET_WM_NAME = disp.intern_atom('_NET_WM_NAME')  # UTF-8
WM_NAME = disp.intern_atom('WM_NAME')           # Legacy encoding

last_seen = { 'xid': None, 'title': None }

@contextmanager
def window_obj(win_id):
    """Simplify dealing with BadWindow (make it either valid or None)"""
    window_obj = None
    if win_id:
        try:
            window_obj = disp.create_resource_object('window', win_id)
        except Xlib.error.XError:
            pass
    yield window_obj

def get_active_window():
    win_id = root.get_full_property(NET_ACTIVE_WINDOW,
                                       Xlib.X.AnyPropertyType).value[0]

    focus_changed = (win_id != last_seen['xid'])
    if focus_changed:
        with window_obj(last_seen['xid']) as old_win:
            if old_win:
                old_win.change_attributes(event_mask=Xlib.X.NoEventMask)

        last_seen['xid'] = win_id
        with window_obj(win_id) as new_win:
            if new_win:
                new_win.change_attributes(event_mask=Xlib.X.PropertyChangeMask)

    return win_id, focus_changed

def _get_window_name_inner(win_obj):
    """Simplify dealing with _NET_WM_NAME (UTF-8) vs. WM_NAME (legacy)"""
    for atom in (NET_WM_NAME, WM_NAME):
        try:
            window_name = win_obj.get_full_property(atom, 0)
        except UnicodeDecodeError:  # Apparently a Debian distro package bug
            title = "<could not decode characters>"
        else:
            if window_name:
                win_name = window_name.value
                if isinstance(win_name, bytes):
                    # Apparently COMPOUND_TEXT is so arcane that this is how
                    # tools like xprop deal with receiving it these days
                    win_name = win_name.decode('latin1', 'replace')
                return win_name
            else:
                title = "<unnamed window>"

    return "{} (XID: {})".format(title, win_obj.id)

def get_window_name(win_id):
    if not win_id:
        last_seen['title'] = "<no window id>"
        return last_seen['title']

    title_changed = False
    with window_obj(win_id) as wobj:
        if wobj:
            win_title = _get_window_name_inner(wobj)
            title_changed = (win_title != last_seen['title'])
            last_seen['title'] = win_title

    return last_seen['title'], title_changed

def handle_xevent(event):
    if event.type != Xlib.X.PropertyNotify:
        return

    changed = False
    if event.atom == NET_ACTIVE_WINDOW:
        if get_active_window()[1]:
            changed = changed or get_window_name(last_seen['xid'])[1]
    elif event.atom in (NET_WM_NAME, WM_NAME):
        changed = changed or get_window_name(last_seen['xid'])[1]

    if changed:
        handle_change(last_seen)

def handle_change(new_state):
    """Replace this with whatever you want to actually do"""
    print(new_state)

if __name__ == '__main__':
    root.change_attributes(event_mask=Xlib.X.PropertyChangeMask)

    get_window_name(get_active_window()[0])
    handle_change(last_seen)

    while True:  # next_event() sleeps until we get an event
        handle_xevent(disp.next_event())

The more fully-commented version I wrote as an example for someone is in this gist.

UPDATE: Now, it also demonstrates the second half (listening to _NET_WM_NAME) to do exactly what was requested.

UPDATE #2: ...and the third part: Falling back to WM_NAME if something like xterm hasn't set _NET_WM_NAME. (The latter is UTF-8 encoded while the former is supposed to use a legacy character coding called compound text but, since nobody seems to know how to work with it, you get programs throwing whatever stream of bytes they have in there and xprop just assuming it'll be ISO-8859-1.)

Related Question