@bitefoundry/retrieviewer 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1094 @@
1
+ #!/usr/bin/env python3
2
+ # Native Linux X11 painter for the existing Braille engine. Python 3.6+ standard
3
+ # library only: ctypes binds the system libX11.so.6, libXrender.so.1 and
4
+ # libXext.so.6 already shipped by the desktop, so there is no compiler, PyPI or
5
+ # npm native ABI (works on AlmaLinux 8 / GLIBC 2.28 platform Python).
6
+ # No input injection, keyboard/pointer grabs, hotkeys, screen capture, network,
7
+ # root access, package installation or target-window modification. Pure Wayland
8
+ # sessions are refused: they cannot safely follow another application's window.
9
+ import ctypes
10
+ import json
11
+ import os
12
+ import select
13
+ import socket
14
+ import sys
15
+ import threading
16
+ import time
17
+ from collections import deque
18
+
19
+ BRAILLE = 0x2800
20
+ CELL_WIDTHS = (6, 8, 10, 12, 16)
21
+ PROTOCOL_MAX_CELLS = 125000
22
+ DEFAULT_MAX_CELLS = 20000
23
+ MAX_LINE = 800000
24
+ MAX_QUEUED = 4
25
+ INK = (248, 242, 232)
26
+ INK_ALPHA = 245
27
+ # Braille bit -> (column, row) inside the 2x4 dot cell (same as Overlay.cs).
28
+ DOTS = ((0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (0, 3), (1, 3))
29
+ MIN_TARGET = (160, 120)
30
+ TRACK_INTERVAL = 1 / 30.0
31
+ RAISE_INTERVAL = 0.25
32
+ TERMINALS = ('terminal', 'konsole', 'xterm', 'urxvt', 'rxvt', 'alacritty', 'kitty', 'wezterm', 'tilix',
33
+ 'terminator', 'terminology', 'sakura', 'guake', 'tilda', 'yakuake', 'st', 'foot', 'ghostty')
34
+
35
+
36
+ class OverlayError(Exception):
37
+ pass
38
+
39
+
40
+ # ---------------------------------------------------------------- pure helpers
41
+ def session_problem(env, platform=None):
42
+ """Return an actionable refusal message, or None when an X11 session is usable."""
43
+ platform = sys.platform if platform is None else platform
44
+ if not platform.startswith('linux'):
45
+ return 'The Linux companion helper runs only on Linux X11 desktops'
46
+ if env.get('WAYLAND_DISPLAY') or env.get('XDG_SESSION_TYPE', '').strip().lower() == 'wayland':
47
+ return ('Wayland session detected. Wayland does not let an app safely follow or stack above another '
48
+ "app's window, so the companion refuses to start. Log out and choose an Xorg session "
49
+ "(for example 'GNOME on Xorg' / 'Standard (X11 display server)' on the login screen), then retry.")
50
+ if not env.get('DISPLAY'):
51
+ return 'DISPLAY is not set. Start the companion from a graphical Xorg desktop session.'
52
+ return None
53
+
54
+
55
+ def strict_int(message, key):
56
+ value = message.get(key)
57
+ if isinstance(value, bool) or not isinstance(value, int):
58
+ raise OverlayError('Invalid overlay field: ' + key)
59
+ return value
60
+
61
+
62
+ def choose_grid(width, height, cell, max_cells=DEFAULT_MAX_CELLS):
63
+ """Columns/rows for a client area; capped so a huge monitor never exceeds max_cells."""
64
+ if cell not in CELL_WIDTHS:
65
+ raise OverlayError('Unsupported cell width')
66
+ max_cells = max(1, min(int(max_cells), PROTOCOL_MAX_CELLS))
67
+ columns, rows = max(0, width // cell), max(0, height // (cell * 2))
68
+ if columns * rows > max_cells:
69
+ scale = (max_cells / float(columns * rows)) ** 0.5
70
+ columns, rows = max(1, int(columns * scale)), max(1, int(rows * scale))
71
+ while columns * rows > max_cells:
72
+ if columns >= rows:
73
+ columns -= 1
74
+ else:
75
+ rows -= 1
76
+ return columns, rows
77
+
78
+
79
+ def parse_frame(message, cell, columns, rows):
80
+ """Validate a cropped frame against the current viewport grid.
81
+
82
+ Returns None for the empty frame (hide), else (x, y, columns, rows, lines)."""
83
+ cx, cy = strict_int(message, 'x'), strict_int(message, 'y')
84
+ cols, nrows = strict_int(message, 'columns'), strict_int(message, 'rows')
85
+ text = message.get('text')
86
+ if not isinstance(text, str):
87
+ raise OverlayError('Invalid overlay field: text')
88
+ if cols == 0 and nrows == 0 and text == '':
89
+ return None
90
+ if cols < 1 or nrows < 1 or cols * nrows > PROTOCOL_MAX_CELLS or cx < 0 or cy < 0 \
91
+ or cx + cols > columns or cy + nrows > rows:
92
+ raise OverlayError('Out-of-bounds overlay frame')
93
+ lines = text.split('\n')
94
+ if len(lines) != nrows:
95
+ raise OverlayError('Invalid overlay row count')
96
+ for line in lines:
97
+ if len(line) != cols:
98
+ raise OverlayError('Invalid overlay column count')
99
+ for ch in line:
100
+ if not BRAILLE <= ord(ch) <= BRAILLE + 0xff:
101
+ raise OverlayError('Only Braille cells may cross the overlay bridge')
102
+ return cx, cy, cols, nrows, lines
103
+
104
+
105
+ class DotTiles(object):
106
+ """Premultiplied ARGB32 (little-endian B,G,R,A bytes) tiles for each Braille mask."""
107
+
108
+ def __init__(self, cell, ink=INK, alpha=INK_ALPHA, samples=4):
109
+ self.cell, self.width, self.height = cell, cell, cell * 2
110
+ step = cell / 2.0
111
+ radius = max(1.1, step * 0.48) / 2.0
112
+ self.dots = []
113
+ for dx, dy in DOTS:
114
+ cx, cy, cover = (dx + 0.5) * step, (dy + 0.5) * step, {}
115
+ for py in range(max(0, int(cy - radius) - 1), min(self.height, int(cy + radius) + 2)):
116
+ for px in range(max(0, int(cx - radius) - 1), min(self.width, int(cx + radius) + 2)):
117
+ hits = 0
118
+ for sy in range(samples):
119
+ for sx in range(samples):
120
+ ox, oy = px + (sx + 0.5) / samples - cx, py + (sy + 0.5) / samples - cy
121
+ if ox * ox + oy * oy <= radius * radius:
122
+ hits += 1
123
+ if hits:
124
+ cover[(px, py)] = hits / float(samples * samples)
125
+ self.dots.append(cover)
126
+ self.ink, self.alpha, self.cache = ink, alpha, {}
127
+
128
+ def tile(self, mask):
129
+ rows = self.cache.get(mask)
130
+ if rows is None:
131
+ pixels = bytearray(self.width * self.height * 4)
132
+ cover = {}
133
+ for bit in range(8):
134
+ if mask & (1 << bit):
135
+ for key, value in self.dots[bit].items():
136
+ cover[key] = min(1.0, cover.get(key, 0.0) + value)
137
+ for (px, py), value in cover.items():
138
+ a = int(round(self.alpha * value))
139
+ i = (py * self.width + px) * 4
140
+ pixels[i:i + 4] = bytes((self.ink[2] * a // 255, self.ink[1] * a // 255, self.ink[0] * a // 255, a))
141
+ stride = self.width * 4
142
+ rows = tuple(bytes(pixels[y * stride:(y + 1) * stride]) for y in range(self.height))
143
+ self.cache[mask] = rows
144
+ return rows
145
+
146
+ def render(self, lines):
147
+ """Rasterize only the cropped Braille box. Fully covers it, so no stale trails."""
148
+ out = []
149
+ for line in lines:
150
+ tiles = [self.tile(ord(ch) - BRAILLE) for ch in line]
151
+ for y in range(self.height):
152
+ out.append(b''.join(t[y] for t in tiles))
153
+ return b''.join(out)
154
+
155
+ def dot_count(self, lines):
156
+ return sum(bin(ord(ch) - BRAILLE).count('1') for line in lines for ch in line)
157
+
158
+
159
+ def encode(message):
160
+ return json.dumps(message, separators=(',', ':'), ensure_ascii=True)
161
+
162
+
163
+ class Companion(object):
164
+ """Protocol state machine. The UI backend owns every native resource."""
165
+
166
+ def __init__(self, ui, emit, cell=16, max_cells=DEFAULT_MAX_CELLS, test_mode=False):
167
+ if cell not in CELL_WIDTHS:
168
+ raise OverlayError('Unsupported cell width')
169
+ self.ui, self.emit, self.cell, self.max_cells, self.test_mode = ui, emit, cell, max_cells, test_mode
170
+ self.revision, self.visible, self.paused, self.loaded = 0, False, False, False
171
+ self.picking, self.quitting, self.announced = False, False, False
172
+ self.canvas, self.grid, self.status = (0, 0, 0, 0), (0, 0), 'starting'
173
+ self.tiles, self.painted_dots = DotTiles(cell), 0
174
+
175
+ # -- input from the Node engine
176
+ def handle(self, message):
177
+ if not isinstance(message, dict):
178
+ raise OverlayError('Invalid overlay message')
179
+ kind = message.get('type')
180
+ if kind == 'quit':
181
+ self.quit()
182
+ elif kind == 'loaded':
183
+ self.loaded = True
184
+ self.set_status('playing')
185
+ if self.test_mode:
186
+ self.press('ball')
187
+ elif kind == 'interaction':
188
+ self.set_status('good!' if message.get('accepted') is True else 'busy, wait')
189
+ elif kind == 'error':
190
+ self.set_status('engine error')
191
+ self.quit()
192
+ elif kind == 'frame':
193
+ # Always acknowledge discarded frames too, otherwise resizing can deadlock the sender.
194
+ painted = self.visible and not self.paused and strict_int(message, 'revision') == self.revision
195
+ if painted:
196
+ self.paint(message)
197
+ self.emit({'type': 'ack', 'painted': painted})
198
+ else:
199
+ raise OverlayError('Unsupported overlay message')
200
+
201
+ def paint(self, message):
202
+ frame = parse_frame(message, self.cell, self.grid[0], self.grid[1])
203
+ if frame is None:
204
+ self.ui.hide_pet()
205
+ return
206
+ cx, cy, cols, rows, lines = frame
207
+ data = self.tiles.render(lines)
208
+ self.painted_dots = self.tiles.dot_count(lines)
209
+ self.ui.paint_pet(self.canvas[0] + cx * self.cell, self.canvas[1] + cy * self.cell * 2,
210
+ cols * self.cell, rows * self.cell * 2, data)
211
+
212
+ # -- native control strip / picker
213
+ def press(self, name):
214
+ if self.quitting:
215
+ return
216
+ if name in ('ball', 'treat'):
217
+ if self.loaded and not self.paused:
218
+ self.emit({'type': 'interact', 'kind': name})
219
+ elif name == 'pause':
220
+ self.paused = not self.paused
221
+ self.emit({'type': 'pause', 'paused': self.paused})
222
+ self.track(True)
223
+ elif name == 'window':
224
+ self.choose_target()
225
+ elif name == 'quit':
226
+ self.quit()
227
+
228
+ def choose_target(self):
229
+ if self.picking or self.quitting:
230
+ return
231
+ self.picking = True
232
+ self.track(True)
233
+ self.ui.open_picker()
234
+
235
+ def picker_shown(self, visible):
236
+ self.emit({'type': 'picker', 'visible': bool(visible)})
237
+
238
+ def picker_closed(self, attached):
239
+ self.picking = False
240
+ if attached:
241
+ self.paused = False
242
+ self.emit({'type': 'pause', 'paused': False})
243
+ self.emit({'type': 'picker', 'visible': False})
244
+ if not self.quitting:
245
+ if not self.ui.has_target():
246
+ self.set_status('choose a window')
247
+ self.track(True)
248
+
249
+ def set_status(self, text):
250
+ self.status = text
251
+ self.ui.status_changed(text)
252
+
253
+ # -- geometry/visibility tracking
254
+ def start(self):
255
+ if not self.test_mode:
256
+ self.choose_target()
257
+ self.ui.wait_for_picker()
258
+ self.announced = True
259
+ self.emit({'type': 'ready', 'pickerVisible': bool(self.ui.picker_visible())})
260
+ self.track()
261
+
262
+ def track(self, force=False):
263
+ if self.quitting:
264
+ return
265
+ state = self.ui.target_state() # None, or (x, y, width, height, active)
266
+ show, canvas = False, (0, 0, 0, 0)
267
+ if state is not None:
268
+ canvas = tuple(state[:4])
269
+ show = (not self.picking and (self.test_mode or state[4])
270
+ and canvas[2] >= MIN_TARGET[0] and canvas[3] >= MIN_TARGET[1])
271
+ grid = choose_grid(canvas[2], canvas[3], self.cell, self.max_cells)
272
+ if force or show != self.visible or canvas != self.canvas or grid != self.grid:
273
+ self.visible, self.canvas, self.grid = show, canvas, grid
274
+ self.revision += 1
275
+ self.ui.hide_pet()
276
+ self.emit({'type': 'viewport', 'revision': self.revision, 'visible': show and not self.paused,
277
+ 'selected': state is not None, 'columns': grid[0], 'rows': grid[1],
278
+ 'cellWidth': self.cell, 'cellHeight': self.cell * 2})
279
+ if self.visible:
280
+ self.ui.show_bar(canvas, self.paused)
281
+ if self.paused:
282
+ self.ui.hide_pet()
283
+ else:
284
+ self.ui.hide_pet()
285
+ self.ui.hide_bar()
286
+
287
+ def quit(self):
288
+ if self.quitting:
289
+ return
290
+ self.quitting = True
291
+ try:
292
+ self.ui.close()
293
+ finally:
294
+ self.emit({'type': 'quit'})
295
+
296
+
297
+ # ---------------------------------------------------------------- Xlib ABI
298
+ c_ulong, c_long, c_int, c_uint, c_void_p = ctypes.c_ulong, ctypes.c_long, ctypes.c_int, ctypes.c_uint, ctypes.c_void_p
299
+ XID = c_ulong # Window, Atom, Colormap, Pixmap, VisualID, Time are all unsigned long XIDs.
300
+
301
+
302
+ class XVisualInfo(ctypes.Structure):
303
+ _fields_ = [('visual', c_void_p), ('visualid', c_ulong), ('screen', c_int), ('depth', c_int),
304
+ ('c_class', c_int), ('red_mask', c_ulong), ('green_mask', c_ulong), ('blue_mask', c_ulong),
305
+ ('colormap_size', c_int), ('bits_per_rgb', c_int)]
306
+
307
+
308
+ class XSetWindowAttributes(ctypes.Structure):
309
+ _fields_ = [('background_pixmap', XID), ('background_pixel', c_ulong), ('border_pixmap', XID),
310
+ ('border_pixel', c_ulong), ('bit_gravity', c_int), ('win_gravity', c_int),
311
+ ('backing_store', c_int), ('backing_planes', c_ulong), ('backing_pixel', c_ulong),
312
+ ('save_under', c_int), ('event_mask', c_long), ('do_not_propagate_mask', c_long),
313
+ ('override_redirect', c_int), ('colormap', XID), ('cursor', XID)]
314
+
315
+
316
+ class XWindowAttributes(ctypes.Structure):
317
+ _fields_ = [('x', c_int), ('y', c_int), ('width', c_int), ('height', c_int), ('border_width', c_int),
318
+ ('depth', c_int), ('visual', c_void_p), ('root', XID), ('c_class', c_int),
319
+ ('bit_gravity', c_int), ('win_gravity', c_int), ('backing_store', c_int),
320
+ ('backing_planes', c_ulong), ('backing_pixel', c_ulong), ('save_under', c_int),
321
+ ('colormap', XID), ('map_installed', c_int), ('map_state', c_int),
322
+ ('all_event_masks', c_long), ('your_event_mask', c_long), ('do_not_propagate_mask', c_long),
323
+ ('override_redirect', c_int), ('screen', c_void_p)]
324
+
325
+
326
+ class XWMHints(ctypes.Structure):
327
+ _fields_ = [('flags', c_long), ('input', c_int), ('initial_state', c_int), ('icon_pixmap', XID),
328
+ ('icon_window', XID), ('icon_x', c_int), ('icon_y', c_int), ('icon_mask', XID),
329
+ ('window_group', XID)]
330
+
331
+
332
+ class XImageFuncs(ctypes.Structure):
333
+ _fields_ = [(name, c_void_p) for name in
334
+ ('create_image', 'destroy_image', 'get_pixel', 'put_pixel', 'sub_image', 'add_pixel')]
335
+
336
+
337
+ class XImage(ctypes.Structure):
338
+ _fields_ = [('width', c_int), ('height', c_int), ('xoffset', c_int), ('format', c_int),
339
+ ('data', c_void_p), ('byte_order', c_int), ('bitmap_unit', c_int),
340
+ ('bitmap_bit_order', c_int), ('bitmap_pad', c_int), ('depth', c_int),
341
+ ('bytes_per_line', c_int), ('bits_per_pixel', c_int), ('red_mask', c_ulong),
342
+ ('green_mask', c_ulong), ('blue_mask', c_ulong), ('obdata', c_void_p), ('f', XImageFuncs)]
343
+
344
+
345
+ class XRectangle(ctypes.Structure):
346
+ _fields_ = [('x', ctypes.c_short), ('y', ctypes.c_short), ('width', ctypes.c_ushort),
347
+ ('height', ctypes.c_ushort)]
348
+
349
+
350
+ class XRenderDirectFormat(ctypes.Structure):
351
+ _fields_ = [(name, ctypes.c_short) for name in
352
+ ('red', 'redMask', 'green', 'greenMask', 'blue', 'blueMask', 'alpha', 'alphaMask')]
353
+
354
+
355
+ class XRenderPictFormat(ctypes.Structure):
356
+ _fields_ = [('id', XID), ('type', c_int), ('depth', c_int), ('direct', XRenderDirectFormat),
357
+ ('colormap', XID)]
358
+
359
+
360
+ class XErrorEvent(ctypes.Structure):
361
+ _fields_ = [('type', c_int), ('display', c_void_p), ('resourceid', XID), ('serial', c_ulong),
362
+ ('error_code', ctypes.c_ubyte), ('request_code', ctypes.c_ubyte), ('minor_code', ctypes.c_ubyte)]
363
+
364
+
365
+ class XAnyEvent(ctypes.Structure):
366
+ _fields_ = [('type', c_int), ('serial', c_ulong), ('send_event', c_int), ('display', c_void_p),
367
+ ('window', XID)]
368
+
369
+
370
+ class XButtonEvent(ctypes.Structure):
371
+ _fields_ = XAnyEvent._fields_ + [('root', XID), ('subwindow', XID), ('time', c_ulong), ('x', c_int),
372
+ ('y', c_int), ('x_root', c_int), ('y_root', c_int), ('state', c_uint),
373
+ ('button', c_uint), ('same_screen', c_int)]
374
+
375
+
376
+ class XExposeEvent(ctypes.Structure):
377
+ _fields_ = XAnyEvent._fields_ + [('x', c_int), ('y', c_int), ('width', c_int), ('height', c_int),
378
+ ('count', c_int)]
379
+
380
+
381
+ class XClientMessageEvent(ctypes.Structure):
382
+ _fields_ = XAnyEvent._fields_ + [('message_type', XID), ('format', c_int), ('l', c_long * 5)]
383
+
384
+
385
+ class XEvent(ctypes.Union):
386
+ _fields_ = [('type', c_int), ('xany', XAnyEvent), ('xbutton', XButtonEvent), ('xexpose', XExposeEvent),
387
+ ('xclient', XClientMessageEvent), ('pad', c_long * 24)]
388
+
389
+
390
+ ERROR_HANDLER = ctypes.CFUNCTYPE(c_int, c_void_p, ctypes.POINTER(XErrorEvent))
391
+ IO_ERROR_HANDLER = ctypes.CFUNCTYPE(c_int, c_void_p)
392
+
393
+ # X.h / Xutil.h / shapeconst.h / render.h constants
394
+ TrueColor, InputOutput, AllocNone, ZPixmap, LSBFirst = 4, 1, 0, 2, 0
395
+ CWBackPixel, CWBorderPixel, CWOverrideRedirect, CWEventMask, CWColormap = 1 << 1, 1 << 3, 1 << 9, 1 << 11, 1 << 13
396
+ ButtonPressMask, ExposureMask, StructureNotifyMask = 1 << 2, 1 << 15, 1 << 17
397
+ ButtonPress, Expose, MapNotify, ClientMessage = 4, 12, 19, 33
398
+ IsViewable, Success, PropModeReplace, InputHint = 2, 0, 0, 1
399
+ XA_ATOM, XA_CARDINAL, XA_STRING, XA_WINDOW = 4, 6, 31, 33
400
+ BadWindow, BadDrawable = 3, 9
401
+ ShapeBounding, ShapeInput, ShapeSet, Unsorted = 0, 2, 0, 0
402
+ PictTypeDirect = 1
403
+
404
+
405
+ def _bind(lib, name, restype, argtypes):
406
+ fn = getattr(lib, name)
407
+ fn.restype, fn.argtypes = restype, argtypes
408
+ return fn
409
+
410
+
411
+ def load_libraries(loader=ctypes.CDLL):
412
+ """Bind exact prototypes of the system X client libraries (never downloaded)."""
413
+ try:
414
+ x11, xrender, xext = loader('libX11.so.6'), loader('libXrender.so.1'), loader('libXext.so.6')
415
+ except OSError as error:
416
+ raise OverlayError('Missing X11 client library (' + str(error) + '). Install the desktop session '
417
+ 'packages libX11, libXrender and libXext with your system package manager.')
418
+ P, I, U, UL, L = c_void_p, c_int, c_uint, c_ulong, c_long
419
+ PI, PUL = ctypes.POINTER(c_int), ctypes.POINTER(c_ulong)
420
+ x = type('Xlib', (), {})()
421
+ for name, res, args in (
422
+ ('XOpenDisplay', P, [ctypes.c_char_p]), ('XCloseDisplay', I, [P]), ('XDefaultScreen', I, [P]),
423
+ ('XRootWindow', UL, [P, I]), ('XConnectionNumber', I, [P]), ('XFlush', I, [P]), ('XSync', I, [P, I]),
424
+ ('XPending', I, [P]), ('XNextEvent', I, [P, ctypes.POINTER(XEvent)]),
425
+ ('XInternAtom', UL, [P, ctypes.c_char_p, I]), ('XFree', I, [P]),
426
+ ('XGetWindowProperty', I, [P, UL, UL, L, L, I, UL, PUL, PI, PUL, PUL,
427
+ ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))]),
428
+ ('XChangeProperty', I, [P, UL, UL, UL, I, I, P, I]),
429
+ ('XGetSelectionOwner', UL, [P, UL]),
430
+ ('XMatchVisualInfo', I, [P, I, I, I, ctypes.POINTER(XVisualInfo)]),
431
+ ('XCreateColormap', UL, [P, UL, P, I]), ('XFreeColormap', I, [P, UL]),
432
+ ('XCreateWindow', UL, [P, UL, I, I, U, U, U, I, U, P, UL, ctypes.POINTER(XSetWindowAttributes)]),
433
+ ('XDestroyWindow', I, [P, UL]), ('XMapRaised', I, [P, UL]), ('XUnmapWindow', I, [P, UL]),
434
+ ('XRaiseWindow', I, [P, UL]), ('XMoveResizeWindow', I, [P, UL, I, I, U, U]),
435
+ ('XStoreName', I, [P, UL, ctypes.c_char_p]), ('XSetWMHints', I, [P, UL, ctypes.POINTER(XWMHints)]),
436
+ ('XSetWMProtocols', I, [P, UL, PUL, I]),
437
+ ('XGetWindowAttributes', I, [P, UL, ctypes.POINTER(XWindowAttributes)]),
438
+ ('XTranslateCoordinates', I, [P, UL, UL, I, I, PI, PI, PUL]),
439
+ ('XGetInputFocus', I, [P, PUL, PI]),
440
+ ('XCreateGC', P, [P, UL, UL, P]), ('XFreeGC', I, [P, P]), ('XSetForeground', I, [P, P, UL]),
441
+ ('XFillRectangle', I, [P, UL, P, I, I, U, U]), ('XDrawString', I, [P, UL, P, I, I, ctypes.c_char_p, I]),
442
+ ('XInitImage', I, [ctypes.POINTER(XImage)]),
443
+ ('XPutImage', I, [P, UL, P, ctypes.POINTER(XImage), I, I, I, I, U, U]),
444
+ ('XSetErrorHandler', P, [ERROR_HANDLER]), ('XSetIOErrorHandler', P, [IO_ERROR_HANDLER])):
445
+ setattr(x, name, _bind(x11, name, res, args))
446
+ x.XRenderQueryExtension = _bind(xrender, 'XRenderQueryExtension', I, [P, PI, PI])
447
+ x.XRenderFindVisualFormat = _bind(xrender, 'XRenderFindVisualFormat', ctypes.POINTER(XRenderPictFormat), [P, P])
448
+ x.XShapeQueryExtension = _bind(xext, 'XShapeQueryExtension', I, [P, PI, PI])
449
+ x.XShapeQueryVersion = _bind(xext, 'XShapeQueryVersion', I, [P, PI, PI])
450
+ x.XShapeCombineRectangles = _bind(xext, 'XShapeCombineRectangles', None,
451
+ [P, UL, I, I, I, ctypes.POINTER(XRectangle), I, I, I])
452
+ x.XShapeGetRectangles = _bind(xext, 'XShapeGetRectangles', ctypes.POINTER(XRectangle), [P, UL, I, PI, PI])
453
+ return x
454
+
455
+
456
+ def ascii_label(text, limit):
457
+ text = ''.join(ch if ' ' <= ch <= '~' else '?' for ch in text)
458
+ return text if len(text) <= limit else text[:limit - 3] + '...'
459
+
460
+
461
+ class XDisplay(object):
462
+ """Owned X11 resources: one ARGB pet window, one control strip, one picker."""
463
+ BAR_BUTTONS = (('ball', 'Ball'), ('treat', 'Treat'), ('window', 'Window'), ('pause', 'Pause'), ('quit', 'Quit'))
464
+
465
+ def __init__(self, display_name=None, require_compositor=True, x=None):
466
+ self.x = x or load_libraries()
467
+ self.errors, self.companion, self.dpy = [], None, None
468
+ self._error_cb = ERROR_HANDLER(self._on_error)
469
+ self._io_cb = IO_ERROR_HANDLER(self._on_io_error)
470
+ self.x.XSetErrorHandler(self._error_cb)
471
+ self.x.XSetIOErrorHandler(self._io_cb)
472
+ self.dpy = self.x.XOpenDisplay(display_name.encode() if display_name else None)
473
+ if not self.dpy:
474
+ raise OverlayError('Cannot open X display ' + repr(display_name or os.environ.get('DISPLAY', '')))
475
+ self.windows, self.gcs, self.colormap = [], [], 0
476
+ self.target = self.target_pid = None
477
+ self.pet = self.bar = self.picker = 0
478
+ self.pet_mapped = self.bar_mapped = self.picker_mapped = False
479
+ self.last_image = None
480
+ self.bar_canvas, self.bar_paused, self.status = None, False, 'starting'
481
+ self.picker_items, self.picker_index, self.last_raise, self.owned_target = [], 0, 0.0, False
482
+ self.screen = self.x.XDefaultScreen(self.dpy)
483
+ self.root = self.x.XRootWindow(self.dpy, self.screen)
484
+ self.atoms = {}
485
+ try:
486
+ self._check_extensions(require_compositor)
487
+ except Exception:
488
+ self.close()
489
+ raise
490
+
491
+ # -- capability checks: clear errors instead of an opaque black window
492
+ def atom(self, name):
493
+ if name not in self.atoms:
494
+ self.atoms[name] = self.x.XInternAtom(self.dpy, name.encode(), 0)
495
+ return self.atoms[name]
496
+
497
+ def _check_extensions(self, require_compositor):
498
+ a, b = c_int(), c_int()
499
+ if not self.x.XShapeQueryExtension(self.dpy, ctypes.byref(a), ctypes.byref(b)):
500
+ raise OverlayError('The X server lacks the SHAPE extension needed for a click-through overlay')
501
+ if not self.x.XShapeQueryVersion(self.dpy, ctypes.byref(a), ctypes.byref(b)) or (a.value, b.value) < (1, 1):
502
+ raise OverlayError('The X server SHAPE extension is older than 1.1 (no input shapes)')
503
+ if not self.x.XRenderQueryExtension(self.dpy, ctypes.byref(a), ctypes.byref(b)):
504
+ raise OverlayError('The X server lacks the RENDER extension needed for transparency')
505
+ info = XVisualInfo()
506
+ if not self.x.XMatchVisualInfo(self.dpy, self.screen, 32, TrueColor, ctypes.byref(info)):
507
+ raise OverlayError('No 32-bit ARGB visual is available on this X screen')
508
+ fmt = self.x.XRenderFindVisualFormat(self.dpy, info.visual)
509
+ if not fmt or fmt.contents.type != PictTypeDirect or fmt.contents.direct.alphaMask != 0xff \
510
+ or fmt.contents.direct.alpha != 24 or (info.red_mask, info.green_mask, info.blue_mask) != (0xff0000, 0xff00, 0xff):
511
+ raise OverlayError('The 32-bit visual is not XRender ARGB32')
512
+ self.visual = info.visual
513
+ self.compositor = bool(self.x.XGetSelectionOwner(self.dpy, self.atom('_NET_WM_CM_S%d' % self.screen)))
514
+ if require_compositor and not self.compositor:
515
+ raise OverlayError('No compositing manager is running, so a transparent overlay would paint an opaque '
516
+ 'black box. Enable desktop compositing (most GNOME/KDE/Xfce sessions do) and retry.')
517
+ supported = self.prop_values(self.root, '_NET_SUPPORTED', XA_ATOM)
518
+ if require_compositor and not {self.atom('_NET_CLIENT_LIST'), self.atom('_NET_ACTIVE_WINDOW')} <= set(supported):
519
+ raise OverlayError('The window manager does not publish EWMH _NET_CLIENT_LIST/_NET_ACTIVE_WINDOW, '
520
+ 'so a target window cannot be followed safely')
521
+ self.colormap = self.x.XCreateColormap(self.dpy, self.root, self.visual, AllocNone)
522
+
523
+ # -- error handling: a target window may disappear at any time
524
+ def _on_error(self, display, event):
525
+ e = event.contents
526
+ self.errors.append((e.error_code, e.request_code, e.resourceid))
527
+ return 0
528
+
529
+ def _on_io_error(self, display):
530
+ try:
531
+ sys.stdout.write(encode({'type': 'error', 'message': 'X display connection lost'}) + '\n')
532
+ sys.stdout.flush()
533
+ finally:
534
+ os._exit(1)
535
+ return 0
536
+
537
+ def checked(self, resource):
538
+ """Sync and consume async errors; True when the given foreign resource vanished."""
539
+ self.x.XSync(self.dpy, 0)
540
+ errors, self.errors = self.errors, []
541
+ gone = False
542
+ for code, request, rid in errors:
543
+ if rid == resource and code in (BadWindow, BadDrawable):
544
+ gone = True
545
+ else:
546
+ raise OverlayError('X protocol error %d (request %d)' % (code, request))
547
+ return gone
548
+
549
+ def prop_values(self, window, name, kind, limit=4096):
550
+ actual, fmt, count, after = c_ulong(), c_int(), c_ulong(), c_ulong()
551
+ data = ctypes.POINTER(ctypes.c_ubyte)()
552
+ status = self.x.XGetWindowProperty(self.dpy, window, self.atom(name), 0, limit, 0, kind,
553
+ ctypes.byref(actual), ctypes.byref(fmt), ctypes.byref(count),
554
+ ctypes.byref(after), ctypes.byref(data))
555
+ if status != Success or not data:
556
+ return []
557
+ try:
558
+ if fmt.value == 32: # format-32 properties arrive as C longs, even on 64-bit
559
+ values = ctypes.cast(data, ctypes.POINTER(c_ulong))
560
+ return [values[i] for i in range(count.value)]
561
+ if fmt.value == 8:
562
+ return ctypes.string_at(data, count.value)
563
+ return []
564
+ finally:
565
+ self.x.XFree(data)
566
+
567
+ # -- window creation
568
+ def _window(self, x, y, w, h, override, mask):
569
+ attrs = XSetWindowAttributes()
570
+ attrs.background_pixel, attrs.border_pixel = 0, 0
571
+ attrs.override_redirect, attrs.colormap, attrs.event_mask = 1 if override else 0, self.colormap, mask
572
+ win = self.x.XCreateWindow(self.dpy, self.root, x, y, max(1, w), max(1, h), 0, 32, InputOutput,
573
+ self.visual, CWBackPixel | CWBorderPixel | CWOverrideRedirect | CWEventMask
574
+ | CWColormap, ctypes.byref(attrs))
575
+ if not win:
576
+ raise OverlayError('Cannot create overlay window')
577
+ self.windows.append(win)
578
+ pid = c_ulong(os.getpid())
579
+ self.x.XChangeProperty(self.dpy, win, self.atom('_NET_WM_PID'), XA_CARDINAL, 32, PropModeReplace,
580
+ ctypes.cast(ctypes.byref(pid), c_void_p), 1)
581
+ return win
582
+
583
+ def _gc(self, win):
584
+ gc = self.x.XCreateGC(self.dpy, win, 0, None)
585
+ self.gcs.append(gc)
586
+ return gc
587
+
588
+ def create(self):
589
+ self.pet = self._window(0, 0, 1, 1, True, ExposureMask)
590
+ kind = c_ulong(self.atom('_NET_WM_WINDOW_TYPE_DND')) # compositors draw no shadow/frame for DND
591
+ self.x.XChangeProperty(self.dpy, self.pet, self.atom('_NET_WM_WINDOW_TYPE'), XA_ATOM, 32, PropModeReplace,
592
+ ctypes.cast(ctypes.byref(kind), c_void_p), 1)
593
+ # Empty input shape: every pointer event passes through to the target.
594
+ self.x.XShapeCombineRectangles(self.dpy, self.pet, ShapeInput, 0, 0, None, 0, ShapeSet, Unsorted)
595
+ self.pet_gc = self._gc(self.pet)
596
+ self.bar = self._window(0, 0, 360, 34, True, ExposureMask | ButtonPressMask)
597
+ self.bar_gc = self._gc(self.bar)
598
+ if self.checked(0):
599
+ raise OverlayError('Cannot create overlay windows')
600
+
601
+ def input_shape_rectangles(self, win):
602
+ count, order = c_int(), c_int()
603
+ rects = self.x.XShapeGetRectangles(self.dpy, win, ShapeInput, ctypes.byref(count), ctypes.byref(order))
604
+ if rects:
605
+ self.x.XFree(rects)
606
+ return count.value
607
+
608
+ # -- pet painting
609
+ def paint_pet(self, x, y, w, h, data):
610
+ self.x.XMoveResizeWindow(self.dpy, self.pet, x, y, w, h)
611
+ if not self.pet_mapped:
612
+ self.x.XMapRaised(self.dpy, self.pet)
613
+ self.pet_mapped = True
614
+ self.last_image = (w, h, data)
615
+ self._put(self.pet, self.pet_gc, w, h, data)
616
+
617
+ def _put(self, win, gc, w, h, data):
618
+ image = XImage()
619
+ image.width, image.height, image.format, image.depth = w, h, ZPixmap, 32
620
+ image.byte_order, image.bitmap_unit, image.bitmap_bit_order, image.bitmap_pad = LSBFirst, 32, LSBFirst, 32
621
+ image.bytes_per_line, image.bits_per_pixel = w * 4, 32
622
+ image.red_mask, image.green_mask, image.blue_mask = 0xff0000, 0xff00, 0xff
623
+ buffer = ctypes.c_char_p(data) # borrows the bytes; XPutImage copies them synchronously
624
+ image.data = ctypes.cast(buffer, c_void_p)
625
+ if not self.x.XInitImage(ctypes.byref(image)):
626
+ raise OverlayError('XInitImage rejected the ARGB32 image')
627
+ self.x.XPutImage(self.dpy, win, gc, ctypes.byref(image), 0, 0, 0, 0, w, h)
628
+ self.x.XFlush(self.dpy)
629
+
630
+ def hide_pet(self):
631
+ self.last_image = None
632
+ if self.pet_mapped:
633
+ self.x.XUnmapWindow(self.dpy, self.pet)
634
+ self.pet_mapped = False
635
+
636
+ # -- control strip
637
+ def show_bar(self, canvas, paused):
638
+ width, height = 6 + 58 * len(self.BAR_BUTTONS) + 120, 34
639
+ x = max(canvas[0], canvas[0] + canvas[2] - width - 10)
640
+ geometry = (x, canvas[1] + 10, paused)
641
+ if geometry != self.bar_canvas:
642
+ self.bar_canvas, self.bar_paused = geometry, paused
643
+ self.x.XMoveResizeWindow(self.dpy, self.bar, x, canvas[1] + 10, width, height)
644
+ self.draw_bar()
645
+ if not self.bar_mapped:
646
+ self.x.XMapRaised(self.dpy, self.bar)
647
+ self.bar_mapped = True
648
+ now = time.time()
649
+ if now - self.last_raise >= RAISE_INTERVAL: # the WM may restack the active target over us
650
+ self.last_raise = now
651
+ if self.pet_mapped:
652
+ self.x.XRaiseWindow(self.dpy, self.pet)
653
+ self.x.XRaiseWindow(self.dpy, self.bar)
654
+
655
+ def hide_bar(self):
656
+ if self.bar_mapped:
657
+ self.x.XUnmapWindow(self.dpy, self.bar)
658
+ self.bar_mapped, self.bar_canvas = False, None
659
+
660
+ def status_changed(self, text):
661
+ self.status = text
662
+ if self.bar_mapped:
663
+ self.draw_bar()
664
+
665
+ def draw_bar(self):
666
+ gc, width = self.bar_gc, 6 + 58 * len(self.BAR_BUTTONS) + 120
667
+ self.x.XSetForeground(self.dpy, gc, 0xff1c1e22)
668
+ self.x.XFillRectangle(self.dpy, self.bar, gc, 0, 0, width, 34)
669
+ for i, (name, label) in enumerate(self.BAR_BUTTONS):
670
+ if name == 'pause' and self.bar_paused:
671
+ label = 'Resume'
672
+ self.x.XSetForeground(self.dpy, gc, 0xff30333a)
673
+ self.x.XFillRectangle(self.dpy, self.bar, gc, 6 + 58 * i, 4, 54, 26)
674
+ self.x.XSetForeground(self.dpy, gc, 0xffffffff)
675
+ self.x.XDrawString(self.dpy, self.bar, gc, 12 + 58 * i, 21, label.encode(), len(label))
676
+ status = ascii_label(self.status, 18)
677
+ self.x.XDrawString(self.dpy, self.bar, gc, 8 + 58 * len(self.BAR_BUTTONS), 21, status.encode(), len(status))
678
+
679
+ def bar_hit(self, x, y):
680
+ if 4 <= y <= 30 and x >= 6:
681
+ index, offset = divmod(x - 6, 58)
682
+ if index < len(self.BAR_BUTTONS) and offset < 54:
683
+ return self.BAR_BUTTONS[index][0]
684
+ return None
685
+
686
+ # -- explicit window picker (EWMH client list; titles/process names only)
687
+ def client_windows(self):
688
+ mine, result = os.getpid(), []
689
+ for win in self.prop_values(self.root, '_NET_CLIENT_LIST', XA_WINDOW):
690
+ if win in self.windows:
691
+ continue
692
+ pids = self.prop_values(win, '_NET_WM_PID', XA_CARDINAL)
693
+ if self.checked(win):
694
+ continue
695
+ pid = pids[0] if pids else None
696
+ if pid == mine:
697
+ continue
698
+ name = self.prop_values(win, '_NET_WM_NAME', self.atom('UTF8_STRING'))
699
+ if isinstance(name, bytes) and name:
700
+ title = name.decode('utf-8', 'replace')
701
+ else:
702
+ name = self.prop_values(win, 'WM_NAME', XA_STRING)
703
+ title = name.decode('latin-1') if isinstance(name, bytes) else ''
704
+ if self.checked(win) or not title:
705
+ continue
706
+ process = ''
707
+ if pid:
708
+ try:
709
+ with open('/proc/%d/comm' % pid) as handle:
710
+ process = handle.read(64).strip()
711
+ except (IOError, OSError):
712
+ pass
713
+ terminal = any(word == process.lower() or (len(word) > 2 and word in process.lower()) for word in TERMINALS)
714
+ result.append({'window': win, 'pid': pid, 'terminal': terminal,
715
+ 'label': ('[terminal] ' if terminal else '') + title + (' - ' + process if process else '')})
716
+ result.sort(key=lambda item: (not item['terminal'], item['label'].lower()))
717
+ return result
718
+
719
+ PICKER_W, PICKER_ROWS = 600, 14
720
+
721
+ def open_picker(self):
722
+ if self.picker:
723
+ return
724
+ self.picker_items, self.picker_index = self.client_windows(), 0
725
+ height = 72 + 20 * self.PICKER_ROWS + 44
726
+ self.picker = self._window(0, 0, self.PICKER_W, height, False, ExposureMask | ButtonPressMask | StructureNotifyMask)
727
+ self.picker_gc = self._gc(self.picker)
728
+ self.x.XStoreName(self.dpy, self.picker, b'Retrieviewer - choose a window')
729
+ hints = XWMHints()
730
+ hints.flags, hints.input = InputHint, 0 # ask the WM not to move keyboard focus to the picker
731
+ self.x.XSetWMHints(self.dpy, self.picker, ctypes.byref(hints))
732
+ protocol = c_ulong(self.atom('WM_DELETE_WINDOW'))
733
+ self.x.XSetWMProtocols(self.dpy, self.picker, ctypes.byref(protocol), 1)
734
+ self.x.XMapRaised(self.dpy, self.picker)
735
+ self.x.XFlush(self.dpy)
736
+
737
+ def picker_visible(self):
738
+ return bool(self.picker and self.picker_mapped)
739
+
740
+ def wait_for_picker(self, timeout=1.0):
741
+ end = time.time() + timeout
742
+ while self.picker and not self.picker_mapped and time.time() < end:
743
+ self.pump()
744
+ time.sleep(0.01)
745
+
746
+ def close_picker(self, attach):
747
+ if not self.picker:
748
+ return
749
+ if attach and self.picker_items:
750
+ item = self.picker_items[self.picker_index]
751
+ self.target, self.target_pid = item['window'], item['pid']
752
+ self._destroy(self.picker)
753
+ self.gcs.remove(self.picker_gc)
754
+ self.x.XFreeGC(self.dpy, self.picker_gc)
755
+ self.picker, self.picker_mapped = 0, False
756
+ self.companion.picker_closed(bool(attach and self.picker_items))
757
+
758
+ def draw_picker(self):
759
+ gc, d, w = self.picker_gc, self.dpy, self.picker
760
+ self.x.XSetForeground(d, gc, 0xff202227)
761
+ self.x.XFillRectangle(d, w, gc, 0, 0, self.PICKER_W, 72 + 20 * self.PICKER_ROWS + 44)
762
+ self.x.XSetForeground(d, gc, 0xffffffff)
763
+ for i, line in enumerate(('Choose the window Retrieviewer should keep company.',
764
+ 'Only its position/size is followed; contents are never read.')):
765
+ self.x.XDrawString(d, w, gc, 14, 22 + 18 * i, line.encode(), len(line))
766
+ if not self.picker_items:
767
+ text = '(no windows found - press Refresh)'
768
+ self.x.XDrawString(d, w, gc, 14, 90, text.encode(), len(text))
769
+ for i, item in enumerate(self.picker_items[:self.PICKER_ROWS]):
770
+ top = 64 + 20 * i
771
+ if i == self.picker_index:
772
+ self.x.XSetForeground(d, gc, 0xff3b5bdb)
773
+ self.x.XFillRectangle(d, w, gc, 8, top, self.PICKER_W - 16, 20)
774
+ self.x.XSetForeground(d, gc, 0xffffffff)
775
+ label = ascii_label(item['label'], 92)
776
+ self.x.XDrawString(d, w, gc, 14, top + 15, label.encode(), len(label))
777
+ for label, left in self._picker_buttons():
778
+ self.x.XSetForeground(d, gc, 0xff3a3d45)
779
+ self.x.XFillRectangle(d, w, gc, left, self._button_top(), 120, 30)
780
+ self.x.XSetForeground(d, gc, 0xffffffff)
781
+ self.x.XDrawString(d, w, gc, left + 12, self._button_top() + 20, label.encode(), len(label))
782
+
783
+ def _button_top(self):
784
+ return 72 + 20 * self.PICKER_ROWS + 6
785
+
786
+ def _picker_buttons(self):
787
+ return (('Refresh', 14), ('Attach', self.PICKER_W - 268), ('Cancel', self.PICKER_W - 134))
788
+
789
+ def picker_click(self, x, y):
790
+ if 64 <= y < 64 + 20 * self.PICKER_ROWS:
791
+ index = (y - 64) // 20
792
+ if index < min(len(self.picker_items), self.PICKER_ROWS):
793
+ self.picker_index = index
794
+ self.draw_picker()
795
+ return
796
+ top = self._button_top()
797
+ for label, left in self._picker_buttons():
798
+ if left <= x < left + 120 and top <= y < top + 30:
799
+ if label == 'Refresh':
800
+ self.picker_items, self.picker_index = self.client_windows(), 0
801
+ self.draw_picker()
802
+ else:
803
+ self.close_picker(label == 'Attach')
804
+ return
805
+
806
+ # -- target tracking (read-only queries; never configures the target)
807
+ def has_target(self):
808
+ return self.target is not None
809
+
810
+ def target_state(self):
811
+ if self.target is None:
812
+ return None
813
+ win = self.target
814
+ clients = self.prop_values(self.root, '_NET_CLIENT_LIST', XA_WINDOW)
815
+ pids = self.prop_values(win, '_NET_WM_PID', XA_CARDINAL)
816
+ attrs = XWindowAttributes()
817
+ ok = self.x.XGetWindowAttributes(self.dpy, win, ctypes.byref(attrs))
818
+ dx, dy, child = c_int(), c_int(), c_ulong()
819
+ moved = self.x.XTranslateCoordinates(self.dpy, win, self.root, 0, 0, ctypes.byref(dx), ctypes.byref(dy),
820
+ ctypes.byref(child))
821
+ states = self.prop_values(win, '_NET_WM_STATE', XA_ATOM)
822
+ gone = self.checked(win)
823
+ if gone or not ok or not moved or (win not in clients and not self.owned_target) \
824
+ or (self.target_pid is not None and (pids[:1] or [None])[0] != self.target_pid):
825
+ self.target = self.target_pid = None
826
+ return None
827
+ if attrs.map_state != IsViewable or self.atom('_NET_WM_STATE_HIDDEN') in states:
828
+ return (dx.value, dy.value, 0, 0, False)
829
+ active = self.prop_values(self.root, '_NET_ACTIVE_WINDOW', XA_WINDOW)
830
+ return (dx.value, dy.value, attrs.width, attrs.height, bool(active) and active[0] == win)
831
+
832
+ # -- events
833
+ def pump(self):
834
+ event = XEvent()
835
+ while self.dpy and self.x.XPending(self.dpy):
836
+ self.x.XNextEvent(self.dpy, ctypes.byref(event))
837
+ win, kind = event.xany.window, event.type
838
+ if kind == Expose and event.xexpose.count == 0:
839
+ if win == self.bar:
840
+ self.draw_bar()
841
+ elif win == self.picker:
842
+ self.draw_picker()
843
+ elif win == self.pet and self.last_image:
844
+ self._put(self.pet, self.pet_gc, *self.last_image)
845
+ elif kind == ButtonPress and event.xbutton.button == 1:
846
+ if win == self.bar and self.companion:
847
+ name = self.bar_hit(event.xbutton.x, event.xbutton.y)
848
+ if name:
849
+ self.companion.press(name)
850
+ elif win == self.picker:
851
+ self.picker_click(event.xbutton.x, event.xbutton.y)
852
+ elif kind == MapNotify and win == self.picker and not self.picker_mapped:
853
+ self.picker_mapped = True
854
+ if self.companion and self.companion.announced:
855
+ self.companion.picker_shown(True)
856
+ elif kind == ClientMessage and win == self.picker and event.xclient.l[0] == self.atom('WM_DELETE_WINDOW'):
857
+ self.close_picker(False)
858
+ if not self.dpy:
859
+ return
860
+ if self.errors and self.dpy:
861
+ self.checked(0)
862
+
863
+ def fileno(self):
864
+ return self.x.XConnectionNumber(self.dpy)
865
+
866
+ def _destroy(self, win):
867
+ if win in self.windows:
868
+ self.windows.remove(win)
869
+ self.x.XDestroyWindow(self.dpy, win)
870
+
871
+ def close(self):
872
+ """Release every owned X resource. Never touches the target window."""
873
+ if not self.dpy:
874
+ return
875
+ for gc in self.gcs:
876
+ self.x.XFreeGC(self.dpy, gc)
877
+ for win in list(self.windows):
878
+ self._destroy(win)
879
+ if self.colormap:
880
+ self.x.XFreeColormap(self.dpy, self.colormap)
881
+ self.x.XSync(self.dpy, 1)
882
+ self.x.XCloseDisplay(self.dpy)
883
+ self.dpy, self.gcs, self.windows, self.colormap = None, [], [], 0
884
+
885
+
886
+ # ---------------------------------------------------------------- process glue
887
+ _output_lock = threading.Lock()
888
+
889
+
890
+ def emit(message):
891
+ with _output_lock:
892
+ try:
893
+ sys.stdout.write(encode(message) + '\n')
894
+ sys.stdout.flush()
895
+ except (IOError, OSError, ValueError):
896
+ os._exit(0)
897
+
898
+
899
+ def read_input(inbox, wake):
900
+ """Stdin reader thread. Xlib is only ever used from the main thread."""
901
+ stream = sys.stdin.buffer
902
+ try:
903
+ while True:
904
+ line = stream.readline(MAX_LINE + 2)
905
+ if not line:
906
+ break
907
+ if len(line) > MAX_LINE:
908
+ inbox.append(OverlayError('Oversized overlay message'))
909
+ break
910
+ try:
911
+ inbox.append(json.loads(line.decode('utf-8')))
912
+ except ValueError:
913
+ inbox.append(OverlayError('Malformed overlay message'))
914
+ break
915
+ os.write(wake, b'.')
916
+ except (IOError, OSError):
917
+ pass
918
+ inbox.append(None) # stdin EOF: exit and release resources
919
+ try:
920
+ os.write(wake, b'.')
921
+ except OSError:
922
+ pass
923
+
924
+
925
+ def single_instance(test):
926
+ """Abstract unix socket lock: released by the kernel even after a crash; no files."""
927
+ lock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
928
+ name = '\0bitefoundry-retrieviewer-overlay-%d' % os.getuid() + ('-diag-%d' % os.getpid() if test else '')
929
+ try:
930
+ lock.bind(name)
931
+ except OSError:
932
+ lock.close()
933
+ raise OverlayError('A Retrieviewer companion is already running')
934
+ return lock
935
+
936
+
937
+ def run(argv):
938
+ test = argv == ['--ipc-test']
939
+ cell = 16
940
+ if argv[:1] == ['--cell'] and len(argv) == 2 and argv[1].isdigit():
941
+ cell = int(argv[1])
942
+ elif argv and not test:
943
+ raise OverlayError('Unsupported native painter argument')
944
+ problem = session_problem(os.environ)
945
+ if problem:
946
+ raise OverlayError(problem)
947
+ lock, ui = single_instance(test), None
948
+ try:
949
+ ui = XDisplay()
950
+ ui.create()
951
+ companion = Companion(ui, emit, cell=cell, test_mode=test)
952
+ ui.companion = companion
953
+ if test: # owned off-screen stand-in target; no user window is touched
954
+ ui.target = ui._window(-12000, -12000, 1280, 800, True, 0)
955
+ ui.owned_target = True
956
+ ui.x.XMapRaised(ui.dpy, ui.target)
957
+ inbox, (wake_r, wake_w) = deque(), os.pipe()
958
+ reader = threading.Thread(target=read_input, args=(inbox, wake_w))
959
+ reader.daemon = True
960
+ reader.start()
961
+ companion.start()
962
+ last_track = 0.0
963
+ while not companion.quitting:
964
+ ui.pump()
965
+ if len(inbox) > MAX_QUEUED + 1:
966
+ raise OverlayError('Overlay input queue overflow')
967
+ while inbox and not companion.quitting:
968
+ message = inbox.popleft()
969
+ if message is None:
970
+ companion.quit()
971
+ elif isinstance(message, Exception):
972
+ raise message
973
+ else:
974
+ companion.handle(message)
975
+ if companion.quitting:
976
+ break
977
+ now = time.time()
978
+ if now - last_track >= TRACK_INTERVAL:
979
+ last_track = now
980
+ companion.track()
981
+ ui.x.XFlush(ui.dpy)
982
+ ready, _, _ = select.select([ui.fileno(), wake_r], [], [], TRACK_INTERVAL)
983
+ if wake_r in ready:
984
+ os.read(wake_r, 4096)
985
+ return 0
986
+ finally:
987
+ if ui:
988
+ ui.close()
989
+ lock.close()
990
+
991
+
992
+ def check(env=None):
993
+ """--check-json: environment diagnostics only; never creates windows."""
994
+ env = os.environ if env is None else env
995
+ report = {'type': 'check', 'python': '.'.join(map(str, sys.version_info[:3])), 'platform': sys.platform,
996
+ 'ok': False, 'errors': []}
997
+ problem = session_problem(env)
998
+ if problem:
999
+ report['errors'].append(problem)
1000
+ return report
1001
+ try:
1002
+ ui = XDisplay(require_compositor=True)
1003
+ report.update(compositor=ui.compositor, argb32=True, shapeInput=True, ewmh=True)
1004
+ ui.close()
1005
+ report['ok'] = True
1006
+ except OverlayError as error:
1007
+ report['errors'].append(str(error))
1008
+ return report
1009
+
1010
+
1011
+ def self_test(display_name):
1012
+ """Owned windows only on an explicitly named (e.g. Xvfb) display: no user window, focus or input."""
1013
+ ui = XDisplay(display_name, require_compositor=False)
1014
+ result = {'type': 'self-test', 'display': display_name, 'compositor': ui.compositor}
1015
+ try:
1016
+ focus_before, revert = c_ulong(), c_int()
1017
+ ui.x.XGetInputFocus(ui.dpy, ctypes.byref(focus_before), ctypes.byref(revert))
1018
+ ui.create()
1019
+ target = ui._window(40, 30, 800, 600, True, 0)
1020
+ ui.x.XMapRaised(ui.dpy, target)
1021
+ ui.target, ui.owned_target = target, True
1022
+ sent = []
1023
+ companion = Companion(ui, sent.append, cell=16, test_mode=True)
1024
+ ui.companion = companion
1025
+ companion.start()
1026
+ viewport = sent[-1]
1027
+ frame = {'type': 'frame', 'revision': viewport['revision'], 'x': 2, 'y': 1, 'columns': 3, 'rows': 2,
1028
+ 'text': '⣿⠁⢀\n⠀⣿⡇'}
1029
+ companion.handle(frame)
1030
+
1031
+ def pet_origin():
1032
+ dx, dy, child = c_int(), c_int(), c_ulong()
1033
+ ui.x.XSync(ui.dpy, 0)
1034
+ ui.x.XTranslateCoordinates(ui.dpy, ui.pet, ui.root, 0, 0, ctypes.byref(dx), ctypes.byref(dy), ctypes.byref(child))
1035
+ return dx.value, dy.value
1036
+ result['painted'] = sent[-1] == {'type': 'ack', 'painted': True} and companion.painted_dots == 22
1037
+ result['followGeometry'] = pet_origin() == (40 + 2 * 16, 30 + 1 * 32)
1038
+ result['inputShapeEmpty'] = ui.input_shape_rectangles(ui.pet) == 0
1039
+ ui.x.XMoveResizeWindow(ui.dpy, target, 140, 90, 900, 640)
1040
+ ui.x.XSync(ui.dpy, 0)
1041
+ companion.track()
1042
+ stale = dict(frame, revision=viewport['revision'])
1043
+ companion.handle(stale)
1044
+ result['staleFrameDiscarded'] = sent[-1] == {'type': 'ack', 'painted': False} and not ui.pet_mapped
1045
+ companion.handle(dict(frame, revision=companion.revision))
1046
+ result['followMoved'] = pet_origin() == (140 + 32, 90 + 32)
1047
+ ui.x.XUnmapWindow(ui.dpy, target)
1048
+ ui.x.XSync(ui.dpy, 0)
1049
+ companion.track()
1050
+ result['hideWhenUnmapped'] = not ui.pet_mapped and not companion.visible
1051
+ ui._destroy(target)
1052
+ ui.x.XSync(ui.dpy, 0)
1053
+ ui.owned_target = False
1054
+ companion.track()
1055
+ result['targetGone'] = ui.target is None and sent[-1].get('selected') is False
1056
+ focus_after = c_ulong()
1057
+ ui.x.XGetInputFocus(ui.dpy, ctypes.byref(focus_after), ctypes.byref(revert))
1058
+ result['focusPreserved'] = focus_after.value == focus_before.value
1059
+ companion.quit()
1060
+ result['released'] = ui.dpy is None and sent[-1] == {'type': 'quit'}
1061
+ result['passed'] = all(result[k] for k in ('painted', 'followGeometry', 'inputShapeEmpty', 'staleFrameDiscarded',
1062
+ 'followMoved', 'hideWhenUnmapped', 'targetGone', 'focusPreserved',
1063
+ 'released'))
1064
+ return result
1065
+ finally:
1066
+ ui.close()
1067
+
1068
+
1069
+ def main(argv):
1070
+ try:
1071
+ if argv == ['--check-json']:
1072
+ report = check()
1073
+ print(encode(report))
1074
+ return 0 if report['ok'] else 2
1075
+ if argv[:1] == ['--self-test']:
1076
+ if len(argv) != 2 or not argv[1].startswith(':'):
1077
+ raise OverlayError('--self-test requires an explicit isolated display such as :99 (Xvfb)')
1078
+ problem = session_problem({'DISPLAY': argv[1]})
1079
+ if problem:
1080
+ raise OverlayError(problem)
1081
+ report = self_test(argv[1])
1082
+ print(encode(report))
1083
+ return 0 if report['passed'] else 1
1084
+ return run(argv)
1085
+ except OverlayError as error:
1086
+ emit({'type': 'error', 'message': str(error)})
1087
+ return 2
1088
+ except Exception as error: # never leave the Node side waiting silently
1089
+ emit({'type': 'error', 'message': '%s: %s' % (type(error).__name__, error)})
1090
+ return 1
1091
+
1092
+
1093
+ if __name__ == '__main__':
1094
+ sys.exit(main(sys.argv[1:]))