Robert Cranston authored on 06/06/2026 11:06:46
Showing 1 changed files
... ...
@@ -126,10 +126,14 @@ def watch_windows(sock, changes, handle):
126 126
             handle(sock, {'id': window, **container['window_properties']})
127 127
         for node in nodes:
128 128
             recurse(node)
129
+    # TODO: Race? Subscribe first, handle the `MSG_GET_TREE` response in the
130
+    # `while True`. This is our only use of `sync_reply`, so we could get rid
131
+    # of that if we want.
129 132
     recurse(send(sock, MSG_GET_TREE, sync_reply=True))
130 133
     send(sock, MSG_SUBSCRIBE, ['window', 'shutdown'])
131 134
     while True:
132 135
         tag, msg = recv(sock)
136
+        # TODO: `match`/`case`, requires newer Python.
133 137
         if tag == MSG_SUBSCRIBE:
134 138
             assert msg['success']
135 139
         elif tag == MSG_COMMAND:
... ...
@@ -147,6 +151,7 @@ def replace(sock, props):
147 151
     command(sock, '[id={id}] title_format "{title}"'.format(**props))
148 152
 
149 153
 def restore(sock):
154
+    # TODO: `[all]`, requires newer `i3`.
150 155
     command(sock, '[title=".*"] title_format "%title"')
151 156
 
152 157
 ## Main
Robert Cranston authored on 06/06/2026 11:06:38
Showing 1 changed files
1 1
new file mode 100755
... ...
@@ -0,0 +1,179 @@
1
+#!/usr/bin/env python3
2
+
3
+"""
4
+Many applications put a delimiter and their name as a suffix in the window
5
+title. Try to make it a lowercase prefix instead, with another delimiter, after
6
+decluttering by removing everything but the executable name. We use different
7
+dashes to detect delimiters, and executables in `PATH` as a heuristic to detect
8
+names. We also detect when window titles already conform to our preferred
9
+format, probably because the application was manually configured. Note that it
10
+still has false positives. Also, this is maybe better done by modifying the
11
+actual X11 window name.
12
+
13
+Uses only the Python standard library.
14
+
15
+References:
16
+
17
+-   <https://i3wm.org/docs/ipc.html>
18
+-   <https://docs.python.org/3/library/struct.html#byte-order-size-and-alignment>
19
+-   <https://docs.python.org/3/library/struct.html#format-characters>
20
+-   <https://i3wm.org/docs/userguide.html#pango_markup>
21
+-   <https://docs.gtk.org/Pango/pango_markup.html>
22
+-   <https://docs.gtk.org/glib/func.markup_escape_text.html>
23
+
24
+You can run these tests with `i3-rename-windows --test`:
25
+
26
+>>> rename('Some video - VLC media player')
27
+'vlc:Some video'
28
+
29
+>>> rename('Some website — Mozilla Firefox')
30
+'firefox:Some website'
31
+
32
+>>> rename('Mozilla Firefox')
33
+'firefox'
34
+
35
+>>> rename('Some other - Some unkown')
36
+'Some other - Some unkown'
37
+
38
+>>> rename('vim:the best editor - vim of course.txt')
39
+'vim:the best editor - vim of course.txt'
40
+"""
41
+
42
+## Rename
43
+
44
+import os
45
+import re
46
+
47
+SEP  = ':'
48
+SEPS = '-–—'
49
+EXES = '|'.join(
50
+    re.escape(file)
51
+    for path in os.environ.get('PATH', os.defpath).split(os.pathsep)
52
+    for file in os.listdir(path)
53
+    if os.access(os.path.join(path, file), os.F_OK | os.X_OK)
54
+)
55
+REGEX = re.compile(
56
+    fr'^'
57
+    fr'(?!(?:{EXES}){SEP}[^ ])'
58
+    fr'(?:(?P<name>.*) +[{SEPS}] +)?'
59
+    fr'.*'
60
+    fr'(?P<exe>\b(?i:{EXES})\b)'
61
+    fr'.*'
62
+    fr'$'
63
+)
64
+
65
+def rename(name):
66
+    match = re.match(REGEX, name)
67
+    if match:
68
+        return SEP.join(filter(None, [
69
+            match['exe'].lower(),
70
+            match['name'],
71
+        ]))
72
+    return name
73
+
74
+## Serialization
75
+
76
+import json
77
+import struct
78
+
79
+MAGIC   = b'i3-ipc'
80
+HDR_FMT = '=LL'
81
+HDR_LEN = struct.calcsize(HDR_FMT)
82
+ESCAPE  = [
83
+    ['\\', '\\\\' ],
84
+    ['"',  '\\"'  ],
85
+    ['&',  '&amp;'],
86
+    ['<',  '&lt;' ],
87
+    ['>',  '&gt;' ],
88
+    ['%',  '&#37;'],
89
+]
90
+
91
+def recv(sock):
92
+    assert sock.recv(len(MAGIC)) == MAGIC
93
+    payload_len, tag = struct.unpack(HDR_FMT, sock.recv(HDR_LEN))
94
+    return tag, json.loads(sock.recv(payload_len))
95
+
96
+def send(sock, tag, msg=b'', sync_reply=False):
97
+    payload = msg if type(msg) is bytes else json.dumps(msg).encode()
98
+    header  = struct.pack(HDR_FMT, len(payload), tag)
99
+    sock.sendall(MAGIC + header + payload)
100
+    if sync_reply:
101
+        reply_tag, reply_msg = recv(sock)
102
+        assert reply_tag == tag
103
+        return reply_msg
104
+
105
+def escape(string):
106
+    for old, new in ESCAPE:
107
+        string = string.replace(old, new)
108
+    return string
109
+
110
+## Messages
111
+
112
+MSG_COMMAND   = 0
113
+MSG_SUBSCRIBE = 2
114
+MSG_GET_TREE  = 4
115
+EVT_WINDOW    = 3 | 1 << 31
116
+EVT_SHUTDOWN  = 6 | 1 << 31
117
+
118
+def command(sock, command):
119
+    send(sock, MSG_COMMAND, command.encode())
120
+
121
+def watch_windows(sock, changes, handle):
122
+    def recurse(container):
123
+        window = container['window']
124
+        nodes  = container['nodes']
125
+        if window:
126
+            handle(sock, {'id': window, **container['window_properties']})
127
+        for node in nodes:
128
+            recurse(node)
129
+    recurse(send(sock, MSG_GET_TREE, sync_reply=True))
130
+    send(sock, MSG_SUBSCRIBE, ['window', 'shutdown'])
131
+    while True:
132
+        tag, msg = recv(sock)
133
+        if tag == MSG_SUBSCRIBE:
134
+            assert msg['success']
135
+        elif tag == MSG_COMMAND:
136
+            assert all(r['success'] for r in msg)
137
+        elif tag == EVT_WINDOW:
138
+            if msg['change'] in changes:
139
+                recurse(msg['container'])
140
+        elif tag == EVT_SHUTDOWN:
141
+            return msg['change'] == 'restart'
142
+
143
+## Titles
144
+
145
+def replace(sock, props):
146
+    props['title'] = escape(rename(props['title']))
147
+    command(sock, '[id={id}] title_format "{title}"'.format(**props))
148
+
149
+def restore(sock):
150
+    command(sock, '[title=".*"] title_format "%title"')
151
+
152
+## Main
153
+
154
+import subprocess
155
+import socket
156
+
157
+def run(*args):
158
+    return subprocess.check_output(args, text=True).strip()
159
+
160
+def main():
161
+    while True:
162
+        with socket.socket(socket.AF_UNIX) as sock:
163
+            sock.connect(run('i3', '--get-socket'))
164
+            try:
165
+                if not watch_windows(sock, ['new', 'title'], replace):
166
+                    break
167
+            except KeyboardInterrupt:
168
+                break
169
+            finally:
170
+                restore(sock)
171
+                sock.shutdown(socket.SHUT_RDWR)
172
+
173
+if __name__ == '__main__':
174
+    import sys
175
+    args = iter(sys.argv[1:])
176
+    if next(args, None) == '--test':
177
+        import doctest
178
+        exit(doctest.testmod().failed != 0)
179
+    main()