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.
- ttyengine/__init__.py +45 -0
- ttyengine/ansi.py +201 -0
- ttyengine/ascii_styler.py +473 -0
- ttyengine/components.py +4718 -0
- ttyengine/compositor.py +780 -0
- ttyengine/errors.py +181 -0
- ttyengine/figlet-LICENSE.txt +27 -0
- ttyengine/figlet.py +430 -0
- ttyengine/fonts.zip +0 -0
- ttyengine/image_reader.py +813 -0
- ttyengine/input_handler.py +814 -0
- ttyengine/launcher.py +822 -0
- ttyengine/region.py +180 -0
- ttyengine/scene.py +614 -0
- ttyengine/sound.py +1439 -0
- ttyengine/terminal.py +283 -0
- ttyengine-0.1.0.dist-info/METADATA +903 -0
- ttyengine-0.1.0.dist-info/RECORD +21 -0
- ttyengine-0.1.0.dist-info/WHEEL +5 -0
- ttyengine-0.1.0.dist-info/licenses/LICENSE +373 -0
- ttyengine-0.1.0.dist-info/top_level.txt +1 -0
ttyengine/compositor.py
ADDED
|
@@ -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)
|