ttyengine 0.1.0__py3-none-any.whl

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,780 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import math
5
+ import time
6
+ import shutil
7
+ import subprocess
8
+ from itertools import groupby
9
+ from operator import itemgetter
10
+ from . import ansi
11
+ from .ascii_styler import alphamark, alphaparse, bgrecomp, fgrecomp
12
+
13
+ COVER = {' ': 0.0, '█': 1.0, '▓': 0.75, '▒': 0.5, '░': 0.25, '▀': 0.5, '▄': 0.5, '▌': 0.5, '▐': 0.5}
14
+ _kinds = {}
15
+
16
+
17
+ def scene_of(item):
18
+ return getattr(item, 'scene', item)
19
+
20
+
21
+ def kind(active):
22
+ found = _kinds.get(active)
23
+ if found is None:
24
+ if len(_kinds) > 4096:
25
+ _kinds.clear()
26
+ fa, back, ba = alphaparse(active)
27
+ if fa is None and ba is None:
28
+ found = 0
29
+ else:
30
+ own = bgrecomp.search(active) is not None
31
+ found = 2 if (ba is not None and own) or (fa is not None and (back or not own)) else 1
32
+ _kinds[active] = found
33
+ return found
34
+
35
+
36
+ def clamp(color):
37
+ return tuple(max(0, min(255, int(round(v)))) for v in color)
38
+
39
+
40
+ MIN_REFRESH = 24.0
41
+ MAX_REFRESH = 1000.0
42
+ FALLBACK_REFRESH = 60.0
43
+ SPIN = 0.016 if sys.platform == 'win32' and sys.version_info < (3, 11) else 0.002
44
+ MAX_SPIN = 0.02
45
+ _detected = []
46
+
47
+
48
+ def valid_refresh(value):
49
+ try:
50
+ value = float(value)
51
+ except (TypeError, ValueError):
52
+ return None
53
+ return value if MIN_REFRESH <= value <= MAX_REFRESH else None
54
+
55
+
56
+ def _windows_refresh():
57
+ import ctypes
58
+ dc = ctypes.windll.user32.GetDC(0)
59
+ try:
60
+ return ctypes.windll.gdi32.GetDeviceCaps(dc, 116)
61
+ finally:
62
+ ctypes.windll.user32.ReleaseDC(0, dc)
63
+
64
+
65
+ def _macos_refresh():
66
+ import ctypes
67
+ lib = ctypes.CDLL('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
68
+ lib.CGMainDisplayID.restype = ctypes.c_uint32
69
+ lib.CGDisplayCopyDisplayMode.argtypes = [ctypes.c_uint32]
70
+ lib.CGDisplayCopyDisplayMode.restype = ctypes.c_void_p
71
+ lib.CGDisplayModeGetRefreshRate.argtypes = [ctypes.c_void_p]
72
+ lib.CGDisplayModeGetRefreshRate.restype = ctypes.c_double
73
+ lib.CGDisplayModeRelease.argtypes = [ctypes.c_void_p]
74
+ mode = lib.CGDisplayCopyDisplayMode(lib.CGMainDisplayID())
75
+ if not mode:
76
+ return None
77
+ try:
78
+ return lib.CGDisplayModeGetRefreshRate(mode)
79
+ finally:
80
+ lib.CGDisplayModeRelease(mode)
81
+
82
+
83
+ def _xrandr_refresh():
84
+ exe = shutil.which('xrandr')
85
+ if not exe:
86
+ return None
87
+ out = subprocess.run([exe, '--current'], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
88
+ stderr=subprocess.DEVNULL, timeout=5).stdout.decode('utf-8', 'replace')
89
+ primary = first = None
90
+ current = None
91
+ for line in out.splitlines():
92
+ if line[:1] and not line[:1].isspace():
93
+ current = (' primary ' in line) if ' connected' in line else None
94
+ continue
95
+ if current is None:
96
+ continue
97
+ found = re.search(r'(\d+(?:\.\d+)?)\*', line)
98
+ if found:
99
+ rate = float(found.group(1))
100
+ first = first or rate
101
+ if current:
102
+ primary = primary or rate
103
+ current = None
104
+ return primary or first
105
+
106
+
107
+ def detect_refresh():
108
+ rate = valid_refresh(os.environ.get('TTYENGINE_REFRESH'))
109
+ if rate:
110
+ return rate
111
+ if not _detected:
112
+ if sys.platform == 'win32':
113
+ finders = (_windows_refresh,)
114
+ elif sys.platform == 'darwin':
115
+ finders = (_macos_refresh,)
116
+ else:
117
+ finders = (_xrandr_refresh,)
118
+ found = None
119
+ for finder in finders:
120
+ try:
121
+ found = valid_refresh(finder())
122
+ except Exception:
123
+ found = None
124
+ if found:
125
+ break
126
+ _detected.append(found or FALLBACK_REFRESH)
127
+ return _detected[0]
128
+
129
+
130
+ class Compositor:
131
+ runs = re.compile(b'\x01(?:\x00{0,3}\x01)*')
132
+ sgr = ansi.SGR
133
+
134
+ def __init__(self, no_color=None, vsync=False, refresh=None):
135
+ self.no_color = no_color
136
+ self.vsync = vsync
137
+ self._refresh = None
138
+ self.refresh = refresh
139
+ self._phase = None
140
+ self._spin = SPIN
141
+ self._flush = None
142
+ self.front = None
143
+ self.width = 0
144
+ self.height = 0
145
+ self.known = {}
146
+ self.glows = {}
147
+ self.styles = {}
148
+ self.bgfound = {}
149
+ self.palettes = {}
150
+ self.glasses = {}
151
+ self.emitted = {}
152
+ self.region = None
153
+ self.rotated = {}
154
+ self._monochrome = None
155
+
156
+ def invalidate(self):
157
+ self.front = None
158
+
159
+ @property
160
+ def refresh(self):
161
+ return self._refresh
162
+
163
+ @refresh.setter
164
+ def refresh(self, value):
165
+ if value is not None and valid_refresh(value) is None:
166
+ raise ValueError(f'refresh must be between {MIN_REFRESH:g} and {MAX_REFRESH:g} Hz, got {value!r}')
167
+ self._refresh = None if value is None else float(value)
168
+
169
+ def refresh_rate(self):
170
+ return self._refresh or detect_refresh()
171
+
172
+ def _dwm_flush(self):
173
+ if self._flush is None:
174
+ try:
175
+ import ctypes
176
+ self._flush = ctypes.windll.dwmapi.DwmFlush
177
+ self._flush.restype = ctypes.c_long
178
+ except Exception:
179
+ self._flush = False
180
+ if not self._flush:
181
+ return False
182
+ try:
183
+ return self._flush() == 0
184
+ except Exception:
185
+ self._flush = False
186
+ return False
187
+
188
+ def vblank(self):
189
+ if not self.vsync:
190
+ return False
191
+ if self._refresh is None and sys.platform == 'win32' and self._dwm_flush():
192
+ return True
193
+ period = 1.0 / self.refresh_rate()
194
+ now = time.perf_counter()
195
+ if self._phase is None:
196
+ self._phase = now
197
+ return True
198
+ wait = self._phase + (math.floor((now - self._phase) / period) + 1) * period - now
199
+ if wait > 0:
200
+ deadline = now + wait
201
+ if wait > self._spin:
202
+ asked = wait - self._spin
203
+ time.sleep(asked)
204
+ late = time.perf_counter() - now - asked
205
+ self._spin = max(SPIN, min(MAX_SPIN, max(late * 1.5, self._spin * 0.95)))
206
+ while time.perf_counter() < deadline:
207
+ pass
208
+ return True
209
+
210
+ @staticmethod
211
+ def vacate(old, new, bounds):
212
+ ox, oy, ow, oh = old
213
+ nx, ny, nw, nh = new
214
+ tw, th = bounds
215
+ out = [ansi.wrap(ansi.RESET)]
216
+ for y in range(max(0, oy), min(oy + oh, th)):
217
+ a, b = max(0, ox), min(ox + ow, tw)
218
+ if ny <= y < ny + nh:
219
+ segs = ((a, min(b, nx)), (max(a, nx + nw), b))
220
+ else:
221
+ segs = ((a, b),)
222
+ for s, e in segs:
223
+ if s < e:
224
+ out.append(ansi.wrap(ansi.move(y + 1, s + 1)) + ' ' * (e - s))
225
+ return ''.join(out) if len(out) > 1 else ''
226
+
227
+ def stale(self, width, height):
228
+ return self.front is None or self.width != width or self.height != height
229
+
230
+ @staticmethod
231
+ def bgstyle(color):
232
+ return ansi.bg(int(color[0]), int(color[1]), int(color[2]))
233
+
234
+ @staticmethod
235
+ def parse(scene, text, spaces):
236
+ bg = Compositor.bgstyle(scene.bgcolor)
237
+ rows = []
238
+ for row in text.split('\n'):
239
+ cells = []
240
+ active = ''
241
+ for part in scene.recomp.split(row):
242
+ if not part:
243
+ continue
244
+ if scene.recomp.match(part):
245
+ if part in ansi.RESETS:
246
+ active = ''
247
+ elif part.startswith(ansi.CSI) and part.endswith('m'):
248
+ active += part
249
+ continue
250
+ shape = kind(active)
251
+ if shape == 2:
252
+ for char in part:
253
+ cells.append((char, active, True) if spaces or char != ' ' else None)
254
+ continue
255
+ style = bg + (alphamark.sub('', active) if shape else active)
256
+ for char in part:
257
+ cells.append((char, style) if spaces or char != ' ' else None)
258
+ runs = []
259
+ glass = []
260
+ at = 0
261
+ for width, group in groupby(cells, key=lambda cell: 0 if cell is None else len(cell)):
262
+ items = list(group)
263
+ if width == 2:
264
+ runs.append((at, items))
265
+ elif width == 3:
266
+ glass.append((at, [(cell[0], cell[1]) for cell in items]))
267
+ at += len(items)
268
+ rows.append((len(cells), runs, glass))
269
+ return rows
270
+
271
+ def palette(self, style, base, front):
272
+ key = (style, base, front)
273
+ found = self.palettes.get(key)
274
+ if found is None:
275
+ if len(self.palettes) > 4096:
276
+ self.palettes.clear()
277
+ backs = bgrecomp.findall(style)
278
+ fronts = fgrecomp.findall(style)
279
+ found = self.palettes[key] = (
280
+ tuple(int(v) for v in backs[-1]) if backs else base,
281
+ tuple(int(v) for v in fronts[-1]) if fronts else front,
282
+ )
283
+ return found
284
+
285
+ def see(self, scene, under, char, active):
286
+ base = tuple(scene.bgcolor)
287
+ front = tuple(scene.fgcolor)
288
+ fa, back, ba = alphaparse(active)
289
+ backs = bgrecomp.findall(active)
290
+ fronts = fgrecomp.findall(active)
291
+ own = bool(backs)
292
+ if char == ' ' and not own:
293
+ return None, None
294
+ if (char == ' ' or (fa is not None and fa <= 0)) and (not own or (ba is not None and ba <= 0)):
295
+ return None, None
296
+ ubg, ufg = self.palette(under[1], base, front)
297
+ cover = COVER.get(under[0], 0.0)
298
+ seen = tuple(ubg[k] + (ufg[k] - ubg[k]) * cover for k in range(3))
299
+ reach = COVER.get(under[0], 0.5)
300
+ glyph = tuple(ubg[k] + (ufg[k] - ubg[k]) * reach for k in range(3))
301
+ if fa is not None and not own and under[0] != ' ' and under[0] not in COVER:
302
+ if char == under[0]:
303
+ glyph = ufg
304
+ elif fa < 0.5:
305
+ tone = tuple(ubg[k] + (ufg[k] - ubg[k]) * (1 - fa) for k in range(3))
306
+ kept = fgrecomp.sub('', bgrecomp.sub('', under[1]))
307
+ return (under[0], self.bgstyle(ubg) + kept + ansi.fg(*clamp(tone))), None
308
+ else:
309
+ glyph = ubg
310
+ if own:
311
+ baked = tuple(int(v) for v in backs[-1])
312
+ ground = baked if ba is None else tuple(baked[k] + (seen[k] - base[k]) * (1 - ba) for k in range(3))
313
+ else:
314
+ baked = None
315
+ ground = seen
316
+ style = self.bgstyle(clamp(ground)) + alphamark.sub('', fgrecomp.sub('', bgrecomp.sub('', active)))
317
+ share = None
318
+ if fronts:
319
+ paint = tuple(int(v) for v in fronts[-1])
320
+ if fa is not None:
321
+ old, new = (base, glyph) if back or not own else (baked, ground)
322
+ paint = tuple(paint[k] + (new[k] - old[k]) * (1 - fa) for k in range(3))
323
+ if back or not own:
324
+ share = (1 - fa) * (1 - reach)
325
+ if share <= 0:
326
+ share = None
327
+ style += ansi.fg(*clamp(paint))
328
+ return (char, style), share
329
+
330
+ def glass(self, scene, under, char, active):
331
+ key = (under, char, active, tuple(scene.bgcolor), tuple(scene.fgcolor))
332
+ found = self.glasses.get(key)
333
+ if found is None:
334
+ if len(self.glasses) > 20000:
335
+ self.glasses.clear()
336
+ found = self.glasses[key] = self.see(scene, under, char, active)
337
+ return found
338
+
339
+ def seep(self, glow, rest, base, share):
340
+ match = bgrecomp.search(glow)
341
+ if match is None:
342
+ return rest
343
+ goal = [int(v) for v in match.groups()]
344
+
345
+ def shift(found):
346
+ old = [int(v) for v in found.groups()]
347
+ return ansi.fg(*clamp(old[k] + (goal[k] - base[k]) * share for k in range(3)))
348
+ return fgrecomp.sub(shift, rest)
349
+
350
+ @staticmethod
351
+ def view(sc, width, height):
352
+ return (
353
+ max(0, sc.dimxoffset),
354
+ max(0, -sc.dimyoffset),
355
+ min(width, sc.termx, sc.dimxoffset + sc.dimx),
356
+ min(height, sc.termy, -sc.dimyoffset + sc.dimy),
357
+ )
358
+
359
+ def tint(self, base, red, green, blue, level):
360
+ key = (base, red, green, blue, level)
361
+ style = self.styles.get(key)
362
+ if style is None:
363
+ if len(self.styles) > 50000:
364
+ self.styles.clear()
365
+ amount = level / 256
366
+ style = self.styles[key] = self.bgstyle((
367
+ max(0, min(255, int(round(base[0] + (red - base[0]) * amount)))),
368
+ max(0, min(255, int(round(base[1] + (green - base[1]) * amount)))),
369
+ max(0, min(255, int(round(base[2] + (blue - base[2]) * amount)))),
370
+ ))
371
+ return style
372
+
373
+ def hasbg(self, style):
374
+ found = self.bgfound.get(style)
375
+ if found is None:
376
+ if len(self.bgfound) > 4096:
377
+ self.bgfound.clear()
378
+ found = False
379
+ for match in self.sgr.finditer(style):
380
+ params = match.group(1).split(';')
381
+ i = 0
382
+ while i < len(params):
383
+ code = int(params[i]) if params[i] else 0
384
+ if code == 48 or 40 <= code <= 47 or 100 <= code <= 107:
385
+ found = True
386
+ break
387
+ if code == 38:
388
+ i += 3 if i + 1 < len(params) and params[i + 1] == '5' else 5
389
+ else:
390
+ i += 1
391
+ if found:
392
+ break
393
+ self.bgfound[style] = found
394
+ return found
395
+
396
+ def shine(self, scene, back, spans, lit, plain, warm=None):
397
+ bg = self.bgstyle(scene.bgcolor)
398
+ base = tuple(scene.bgcolor)
399
+ groups = {}
400
+ for light, rows in lit:
401
+ gkey = (light._color, light._mix)
402
+ grows = groups.get(gkey)
403
+ if grows is None:
404
+ grows = groups[gkey] = {}
405
+ for y, x, amounts in rows:
406
+ span = spans.get(y)
407
+ if span is None:
408
+ continue
409
+ lo = max(span[0] - x, 0)
410
+ hi = min(span[1] - x, len(amounts))
411
+ if lo >= hi:
412
+ continue
413
+ if lo or hi < len(amounts):
414
+ amounts = amounts[lo:hi]
415
+ start = x + lo
416
+ stop = start + len(amounts)
417
+ entry = grows.get(y)
418
+ if entry is None:
419
+ entry = grows[y] = [[0.0] * len(back[y]), start, stop]
420
+ else:
421
+ if start < entry[1]:
422
+ entry[1] = start
423
+ if stop > entry[2]:
424
+ entry[2] = stop
425
+ row = entry[0]
426
+ for xx, amount in enumerate(amounts, start):
427
+ row[xx] += amount
428
+ ys = set()
429
+ for grows in groups.values():
430
+ ys.update(grows)
431
+ for y in ys:
432
+ line = back[y]
433
+ entries = [(color, mix, grows[y]) for (color, mix), grows in groups.items() if y in grows]
434
+ if len(entries) == 1:
435
+ color, mix, (row, first, last) = entries[0]
436
+ lut = self.styles.get((base, color))
437
+ if lut is None:
438
+ lut = self.styles[(base, color)] = [None] * 257
439
+ for x in range(first, last):
440
+ total = row[x]
441
+ if total <= 0:
442
+ continue
443
+ level = int(mix * (total if total < 1.0 else 1.0) * 256 + 0.5)
444
+ glow = lut[level]
445
+ if glow is None:
446
+ glow = lut[level] = self.tint(base, color[0], color[1], color[2], level)
447
+ char, style = line[x]
448
+ if style == bg:
449
+ line[x] = (char, glow)
450
+ elif style.startswith(bg):
451
+ rest = style[len(bg):]
452
+ if not self.hasbg(rest):
453
+ if warm:
454
+ entry = warm.get((y, x))
455
+ if entry is not None and entry[1] == line[x]:
456
+ rest = self.seep(glow, rest, base, entry[0])
457
+ line[x] = (char, glow + rest)
458
+ elif style == '' or style == plain:
459
+ line[x] = (char, glow)
460
+ continue
461
+ first = min(e[2][1] for e in entries)
462
+ last = max(e[2][2] for e in entries)
463
+ for x in range(first, last):
464
+ total = mixsum = red = green = blue = 0.0
465
+ for color, mix, (row, a, b) in entries:
466
+ if a <= x < b:
467
+ amount = row[x]
468
+ if amount > 0:
469
+ total += amount
470
+ mixsum += amount * mix
471
+ red += amount * color[0]
472
+ green += amount * color[1]
473
+ blue += amount * color[2]
474
+ if total <= 0:
475
+ continue
476
+ glow = self.tint(base, int(round(red / total)), int(round(green / total)), int(round(blue / total)),
477
+ int(mixsum / total * min(1.0, total) * 256 + 0.5))
478
+ char, style = line[x]
479
+ if style == bg:
480
+ line[x] = (char, glow)
481
+ elif style.startswith(bg):
482
+ rest = style[len(bg):]
483
+ if not self.hasbg(rest):
484
+ if warm:
485
+ entry = warm.get((y, x))
486
+ if entry is not None and entry[1] == line[x]:
487
+ rest = self.seep(glow, rest, base, entry[0])
488
+ line[x] = (char, glow + rest)
489
+ elif style == '' or style == plain:
490
+ line[x] = (char, glow)
491
+
492
+ def sync(self, items, active, width, height):
493
+ rects = []
494
+ seen = set()
495
+ glowing = set()
496
+ known = self.known
497
+ for w in items:
498
+ sc = scene_of(w)
499
+ live = w in active
500
+ if live:
501
+ ir = sc.iround
502
+ camx = ir(sc.camx)
503
+ camy = ir(sc.camy)
504
+ xoff = sc.dimxoffset
505
+ yoff = sc.dimyoffset
506
+ bgcolor = sc.bgcolor
507
+ for o in sc.objs:
508
+ r = getattr(o, 'rendering', None)
509
+ if r is None:
510
+ continue
511
+ seen.add(r)
512
+ if not live or not r.t.rtext:
513
+ continue
514
+ state = known.get(r)
515
+ if state is None:
516
+ state = known[r] = [(0, 0, '', 0, 0), None, [], 0]
517
+ current = r.state(camx, camy, xoff, yoff)
518
+ previous = state[0]
519
+ if previous == current:
520
+ continue
521
+ if previous[2]:
522
+ x, y, _, xo, yo = previous
523
+ rects.append((x + xo, y - yo, x + xo + state[3], y - yo + len(state[2])))
524
+ if current[2]:
525
+ key = (current[2], r.renderspaces, bgcolor)
526
+ if state[1] != key:
527
+ state[1] = key
528
+ state[2] = self.parse(sc, current[2], r.renderspaces)
529
+ state[3] = max(row[0] for row in state[2])
530
+ x, y, _, xo, yo = current
531
+ rects.append((x + xo, y - yo, x + xo + state[3], y - yo + len(state[2])))
532
+ state[0] = current
533
+ if live:
534
+ view = self.view(sc, width, height)
535
+ for light in sc.lights:
536
+ if not light.glowing():
537
+ continue
538
+ glowing.add(light)
539
+ key = (light.glowkey(camx, camy, xoff, yoff, view), bgcolor)
540
+ previous = self.glows.get(light)
541
+ if previous is None or previous[0] != key:
542
+ if previous is not None:
543
+ rects.append(previous[1])
544
+ box = light.glowbox(camx, camy, xoff, yoff, view)
545
+ rects.append(box)
546
+ self.glows[light] = (key, box)
547
+ else:
548
+ glowing.update(light for light in sc.lights if light in self.glows)
549
+ for light in [light for light in self.glows if light not in glowing]:
550
+ rects.append(self.glows.pop(light)[1])
551
+ for r in [r for r in known if r not in seen]:
552
+ state = known.pop(r)
553
+ if state[0][2]:
554
+ x, y, _, xo, yo = state[0]
555
+ rects.append((x + xo, y - yo, x + xo + state[3], y - yo + len(state[2])))
556
+ return rects
557
+
558
+ def layers(self, items, width, height, autobg):
559
+ layers = []
560
+ for w in items:
561
+ sc = scene_of(w)
562
+ view = self.view(sc, width, height)
563
+ ext = self.extent(sc, view) if sc.camrotation % 360.0 else view
564
+ bg = self.bgstyle(sc.bgcolor) if autobg and getattr(w, 'autobg', True) else None
565
+ sprites = []
566
+ for r in sorted([o.rendering for o in sc.objs if hasattr(o, 'rendering')], key=lambda r: r.z):
567
+ state = self.known.get(r)
568
+ if state is None or not state[0][2]:
569
+ continue
570
+ key = (state[0][2], r.renderspaces, sc.bgcolor)
571
+ if state[1] != key:
572
+ state[1] = key
573
+ state[2] = self.parse(sc, state[0][2], r.renderspaces)
574
+ state[3] = max(row[0] for row in state[2])
575
+ x, y, _, xo, yo = state[0]
576
+ sprites.append((x + xo, y - yo, state[2]))
577
+ lit = []
578
+ if view[0] < view[2] and view[1] < view[3]:
579
+ camx = sc.iround(sc.camx)
580
+ camy = sc.iround(sc.camy)
581
+ for light in sc.lights:
582
+ if light.glowing():
583
+ rows = light.glowrows(camx, camy, sc.dimxoffset, sc.dimyoffset, ext)
584
+ if rows:
585
+ lit.append((light, rows))
586
+ layers.append((view, ext, bg, sprites, sc, lit))
587
+ return layers
588
+
589
+ def paint(self, back, spans, view, bg, sprites, sc, lit, plain, top, bottom, right):
590
+ vx0, vy0, vx1, vy1 = view
591
+ warm = {}
592
+ if bg is not None:
593
+ cell = (' ', bg)
594
+ for y in range(max(vy0, top), min(vy1, bottom)):
595
+ span = spans.get(y)
596
+ if span is None:
597
+ continue
598
+ a = max(vx0, span[0])
599
+ b = min(vx1, span[1])
600
+ if a < b:
601
+ back[y][a:b] = [cell] * (b - a)
602
+ if lit and any(row[2] for _, _, grid in sprites for row in grid):
603
+ self.shine(sc, back, spans, lit, plain)
604
+ for x, y, grid in sprites:
605
+ if y >= bottom or y + len(grid) <= top or x >= right:
606
+ continue
607
+ for i, (rowwidth, runs, glass) in enumerate(grid):
608
+ ry = y + i
609
+ if ry < vy0 or ry >= vy1:
610
+ continue
611
+ span = spans.get(ry)
612
+ if span is None:
613
+ continue
614
+ a = max(vx0, span[0], x)
615
+ b = min(vx1, span[1], x + rowwidth)
616
+ if a >= b:
617
+ continue
618
+ line = back[ry]
619
+ for offset, cells in runs:
620
+ s = max(a, x + offset)
621
+ e = min(b, x + offset + len(cells))
622
+ if s < e:
623
+ line[s:e] = cells[s - x - offset:e - x - offset]
624
+ for offset, cells in glass:
625
+ s = max(a, x + offset)
626
+ e = min(b, x + offset + len(cells))
627
+ for xx in range(s, e):
628
+ char, active = cells[xx - x - offset]
629
+ cell, share = self.glass(sc, line[xx], char, active)
630
+ if cell is not None:
631
+ line[xx] = cell
632
+ if share is not None:
633
+ warm[(ry, xx)] = (share, cell)
634
+ if lit:
635
+ self.shine(sc, back, spans, lit, plain, warm)
636
+
637
+ @staticmethod
638
+ def extent(sc, view):
639
+ aspect = sc.aspect or 0.5
640
+ cx = (view[0] + view[2]) / 2
641
+ cy = (view[1] + view[3]) / 2
642
+ radius = math.hypot((view[2] - view[0]) / 2 * aspect, (view[3] - view[1]) / 2)
643
+ rx = int(math.ceil(radius / aspect))
644
+ ry = int(math.ceil(radius))
645
+ return (int(math.floor(cx)) - rx, int(math.floor(cy)) - ry, int(math.ceil(cx)) + rx, int(math.ceil(cy)) + ry)
646
+
647
+ @staticmethod
648
+ def turn(back, local, spans, view, ext, sc, angle, clear):
649
+ vx0, vy0, vx1, vy1 = view
650
+ ex0, ey0, ex1, ey1 = ext
651
+ aspect = sc.aspect or 0.5
652
+ rad = math.radians(angle)
653
+ cos_a = math.cos(rad)
654
+ sin_a = math.sin(rad)
655
+ cx = (vx0 + vx1) / 2
656
+ cy = (vy0 + vy1) / 2
657
+ ew = ex1 - ex0
658
+ eh = ey1 - ey0
659
+ for y, (lo, hi) in spans.items():
660
+ if y < vy0 or y >= vy1:
661
+ continue
662
+ a = max(lo, vx0)
663
+ b = min(hi, vx1)
664
+ if a >= b:
665
+ continue
666
+ line = back[y]
667
+ dy = y + 0.5 - cy
668
+ for x in range(a, b):
669
+ dx = (x + 0.5 - cx) * aspect
670
+ sx = int(math.floor((dx * cos_a - dy * sin_a) / aspect + cx)) - ex0
671
+ sy = int(math.floor(dx * sin_a + dy * cos_a + cy)) - ey0
672
+ if sx < 0 or sx >= ew or sy < 0 or sy >= eh:
673
+ continue
674
+ cell = local[sy][sx]
675
+ if cell is not clear:
676
+ line[x] = cell
677
+
678
+ def emit(self, style, plain):
679
+ key = (style, plain)
680
+ found = self.emitted.get(key)
681
+ if found is None:
682
+ if len(self.emitted) > 50000:
683
+ self.emitted.clear()
684
+ found = self.emitted[key] = ansi.wrap(ansi.RESET) + ansi.wrap_all(ansi.strip_color(style) if plain else style)
685
+ return found
686
+
687
+ def compose(self, items, active, region, bounds, outer, autobg):
688
+ regx, regy, width, height = region
689
+ plain = ansi.monochrome(self.no_color)
690
+ if plain != self._monochrome:
691
+ self._monochrome = plain
692
+ self.invalidate()
693
+ rects = self.sync(items, active, width, height)
694
+ prefix = ''
695
+ moved = self.region is not None and self.region != region
696
+ if moved:
697
+ prefix = self.vacate(self.region, region, bounds)
698
+ self.region = region
699
+ if moved or self.stale(width, height):
700
+ self.front = [[None] * width for _ in range(height)]
701
+ self.width = width
702
+ self.height = height
703
+ rects = [(0, 0, width, height)]
704
+ dirty = bool(rects)
705
+ for w in items:
706
+ sc = scene_of(w)
707
+ if w not in active:
708
+ continue
709
+ angle = sc.camrotation % 360.0
710
+ previous = self.rotated.get(sc)
711
+ if not angle and previous is None:
712
+ continue
713
+ view = self.view(sc, width, height)
714
+ key = (angle, sc.aspect, view)
715
+ if key != previous or dirty:
716
+ rects.append(view)
717
+ if angle:
718
+ self.rotated[sc] = key
719
+ else:
720
+ del self.rotated[sc]
721
+ spans = {}
722
+ for x0, y0, x1, y1 in rects:
723
+ x0 = max(0, x0)
724
+ y0 = max(0, y0)
725
+ x1 = min(width, x1)
726
+ y1 = min(height, y1)
727
+ if x0 >= x1 or y0 >= y1:
728
+ continue
729
+ for y in range(y0, y1):
730
+ span = spans.get(y)
731
+ if span is None:
732
+ spans[y] = [x0, x1]
733
+ else:
734
+ if x0 < span[0]: span[0] = x0
735
+ if x1 > span[1]: span[1] = x1
736
+ if not spans:
737
+ return prefix
738
+ top = min(spans)
739
+ bottom = max(spans) + 1
740
+ right = max(s[1] for s in spans.values())
741
+ base = (' ', self.bgstyle(outer) if outer is not None else '')
742
+ back = {y: [base] * width for y in spans}
743
+ clear = (' ', base[1])
744
+ for view, ext, bg, sprites, sc, lit in self.layers(items, width, height, autobg):
745
+ angle = sc.camrotation % 360.0
746
+ if angle and view[0] < view[2] and view[1] < view[3]:
747
+ ex0, ey0, ex1, ey1 = ext
748
+ ew = ex1 - ex0
749
+ eh = ey1 - ey0
750
+ local = {y: [clear] * ew for y in range(eh)}
751
+ full = {y: [0, ew] for y in range(eh)}
752
+ moved_sprites = [(x - ex0, y - ey0, grid) for x, y, grid in sprites]
753
+ moved_lit = [(light, [(y - ey0, x - ex0, amounts) for y, x, amounts in rows]) for light, rows in lit]
754
+ self.paint(local, full, (0, 0, ew, eh), bg, moved_sprites, sc, moved_lit, base[1], 0, eh, ew)
755
+ self.turn(back, local, spans, view, ext, sc, angle, clear)
756
+ else:
757
+ self.paint(back, spans, view, bg, sprites, sc, lit, base[1], top, bottom, right)
758
+ out = []
759
+ style = None
760
+ wrap = ansi.wrap
761
+ for y in sorted(spans):
762
+ lo, hi = spans[y]
763
+ new = back[y][lo:hi]
764
+ old = self.front[y][lo:hi]
765
+ if new == old:
766
+ continue
767
+ mask = bytes([a != b for a, b in zip(new, old)])
768
+ for run in self.runs.finditer(mask):
769
+ start, end = run.span()
770
+ out.append(wrap(ansi.move(regy + y + 1, regx + lo + start + 1)))
771
+ for cellstyle, group in groupby(new[start:end], key=itemgetter(1)):
772
+ if cellstyle != style:
773
+ style = cellstyle
774
+ out.append(self.emit(style, plain))
775
+ out.append(''.join([cell[0] for cell in group]))
776
+ self.front[y][lo + start:lo + end] = new[start:end]
777
+ if not out:
778
+ return prefix
779
+ out.append(wrap(ansi.RESET))
780
+ return prefix + ''.join(out)