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/launcher.py ADDED
@@ -0,0 +1,822 @@
1
+ import os, sys, time, threading, traceback, contextlib, inspect, ctypes, socket, subprocess, tempfile, shlex
2
+ from types import SimpleNamespace
3
+ from . import ansi
4
+ from .errors import attempt, highlight_traceback
5
+
6
+ DEBUG_HOLD = ansi.osc('777;hold')
7
+ DEBUG_RECEIVER = r"""
8
+ import sys, socket, codecs
9
+ if sys.platform == "win32":
10
+ import ctypes
11
+ kernel32 = ctypes.windll.kernel32
12
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
13
+ if __BACK__:
14
+ hwnd = kernel32.GetConsoleWindow()
15
+ if hwnd: ctypes.windll.user32.SetWindowPos(hwnd, 1, 0, 0, 0, 0, 19)
16
+ out = sys.stdout
17
+ out.write(__OPEN__)
18
+ out.flush()
19
+ sock = socket.socket()
20
+ sock.connect(("127.0.0.1", __PORT__))
21
+ decode = codecs.getincrementaldecoder("utf-8")("replace").decode
22
+ hold = False
23
+ marker = __HOLD__
24
+ while True:
25
+ data = sock.recv(65536)
26
+ if not data:
27
+ break
28
+ text = decode(data)
29
+ if marker in text:
30
+ text = text.replace(marker, "")
31
+ hold = True
32
+ out.write(text)
33
+ out.flush()
34
+ out.write(__CLOSE__)
35
+ out.flush()
36
+ if hold:
37
+ try: input()
38
+ except Exception: pass
39
+ """
40
+
41
+ def spawn_console(script):
42
+ if sys.platform == "win32":
43
+ for cmd in (['conhost.exe', sys.executable, '-c', script], [sys.executable, '-c', script]):
44
+ try:
45
+ return subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_CONSOLE)
46
+ except FileNotFoundError:
47
+ continue
48
+ return None
49
+ if sys.platform == "darwin":
50
+ fd, path = tempfile.mkstemp(prefix='ttyengine_debug_', suffix='.py')
51
+ with os.fdopen(fd, 'w') as f:
52
+ f.write(script)
53
+ command = f'{shlex.quote(sys.executable)} {shlex.quote(path)}; rm -f {shlex.quote(path)}; exit'
54
+ command = command.replace('\\', '\\\\').replace('"', '\\"')
55
+ return subprocess.Popen(['osascript', '-e', f'tell application "Terminal" to do script "{command}"'],
56
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
57
+ for term in ['x-terminal-emulator', 'gnome-terminal', 'xterm', 'konsole']:
58
+ try:
59
+ return subprocess.Popen([term, '--' if term == 'gnome-terminal' else '-e', sys.executable, '-c', script],
60
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
61
+ stderr=subprocess.DEVNULL, close_fds=True)
62
+ except FileNotFoundError:
63
+ continue
64
+ return None
65
+
66
+ def format_dinfo(value):
67
+ if value is None:
68
+ lines = []
69
+ elif isinstance(value, str):
70
+ lines = value.split('\n')
71
+ elif isinstance(value, dict):
72
+ lines = [line for k, v in value.items() for line in f'{k}: {v}'.split('\n')]
73
+ elif isinstance(value, (list, tuple, set, frozenset)):
74
+ lines = [line for v in value for line in str(v).split('\n')]
75
+ else:
76
+ lines = str(value).split('\n')
77
+ return ansi.HOME + (ansi.ERASE_LINE + '\n').join(lines) + ansi.ERASE_LINE + ansi.ERASE_BELOW
78
+
79
+ def getargs(func, args):
80
+ if not callable(func): return {}
81
+ sig = inspect.signature(func)
82
+ par = sig.parameters
83
+ if any(p.kind == p.VAR_KEYWORD for p in par.values()):
84
+ return args
85
+ resargs = {k: v for k, v in args.items() if k in par}
86
+ return resargs
87
+
88
+ def latency(samples=1000):
89
+ latencies = []
90
+ for _ in range(samples):
91
+ start = time.perf_counter()
92
+ time.sleep(0)
93
+ end = time.perf_counter()
94
+ latencies.append(end - start)
95
+ return latencies
96
+
97
+ def measure_latency(samples=200, percentile=0.95):
98
+ values = sorted(latency(samples))
99
+ return values[min(len(values) - 1, int(len(values) * percentile))]
100
+
101
+
102
+ #MODULES
103
+ from . import scene
104
+ from . import region as regions
105
+ from .compositor import Compositor
106
+ from .terminal import Terminal, MODE_SYNC
107
+ from .input_handler import inp
108
+ from .sound import Mixer, set_ffmpeg
109
+
110
+ #WINDOWS INTIALIZATION
111
+ def universal_init():
112
+ if os.name == 'nt':
113
+ hOut = ctypes.windll.kernel32.GetStdHandle(-11)
114
+ dwMode = ctypes.c_uint32()
115
+ ctypes.windll.kernel32.GetConsoleMode(hOut, ctypes.byref(dwMode))
116
+ ctypes.windll.kernel32.SetConsoleMode(hOut, dwMode.value | 0x0004)
117
+ try:
118
+ universal_init()
119
+ except Exception:
120
+ pass
121
+
122
+ HANDLE_STATES = ('start', 'update', 'final')
123
+
124
+ _SPIN_MARGIN = 0.016 if sys.platform == 'win32' and sys.version_info < (3, 11) else 0.002
125
+
126
+ def handle_state(state):
127
+ if isinstance(state, str):
128
+ name = state.strip().lower()
129
+ if name in HANDLE_STATES:
130
+ return name
131
+ elif isinstance(state, int) and not isinstance(state, bool) and 0 <= state < len(HANDLE_STATES):
132
+ return HANDLE_STATES[state]
133
+ raise ValueError(f"invalid handle state {state!r}: use 'start'/0, 'update'/1 or 'final'/2")
134
+
135
+ def is_handle(obj):
136
+ return obj is not None and any(callable(getattr(obj, n, None)) for n in HANDLE_STATES)
137
+
138
+ def handle_class(handle, state='start'):
139
+ if inspect.isclass(handle):
140
+ return handle
141
+ if not callable(handle):
142
+ raise TypeError(f"launch() expects a class or a function, got {type(handle).__name__}")
143
+ name = handle_state(state)
144
+ base = getattr(handle, '__name__', None) or type(handle).__name__
145
+ return type(base, (), {
146
+ name: staticmethod(handle),
147
+ '_factory': name == 'start',
148
+ '__doc__': getattr(handle, '__doc__', None),
149
+ '__module__': getattr(handle, '__module__', __name__),
150
+ })
151
+
152
+ #WORLD CLASS
153
+ class World():
154
+ def __init__(self, session=None, autoreg=True, front=True, **kwargs):
155
+ self.session = session
156
+ self.handles = []
157
+ self.scene = scene.Scene()
158
+ self._layer = 0
159
+ self.last_tick = time.perf_counter()
160
+ self.pause = False
161
+ self.pausephysics = False
162
+ self.pauserendering = False
163
+ self.autoterm = True
164
+ self.autodim = True
165
+ self.autobg = True
166
+ self.autofps = True
167
+ self._region = None
168
+ self._placed = False
169
+ for setting, val in kwargs.items():
170
+ if val is not None: setattr(self, setting, val)
171
+ if self.session is not None:
172
+ self.session._fit(self.scene, self)
173
+ if autoreg: attempt(lambda: self.register(front))
174
+
175
+ @property
176
+ def region(self):
177
+ return self._region
178
+ @region.setter
179
+ def region(self, value):
180
+ self._region = None if value is None else regions.parse(value)
181
+ if value is not None:
182
+ self._placed = True
183
+
184
+ @property
185
+ def layer(self):
186
+ return self._layer
187
+ @layer.setter
188
+ def layer(self, value):
189
+ self._layer = value
190
+ self.sort()
191
+
192
+ def sort(self, layer=None):
193
+ if self.session is not None:
194
+ self.session.worlds = sorted(list(self.session.worlds), key=lambda w: getattr(w, '_layer', 0) if w is not self or layer is None else layer)
195
+
196
+ class Handle():
197
+ def __init__(self, handle, world):
198
+ self.world = world
199
+ self.pause = False
200
+ self.handleargs = {
201
+ 'scene': self.world.scene,
202
+ 'handle': self,
203
+ 'world': self.world,
204
+ 'session': self.world.session,
205
+ 'sound': getattr(self.world.session, 'sound', None),
206
+ 'simulation': getattr(self.world.session, 'simulation', None),
207
+ }
208
+ self.handle = handle(**getargs(handle, self.handleargs))
209
+ if not hasattr(self.handle, 'priority'):
210
+ self.priority = 0
211
+ start = getattr(self.handle, 'start', None)
212
+ self.startargs = getargs(start, self.handleargs)
213
+ self.world.handles.append(self)
214
+ result = start(**self.startargs) if start else None
215
+ if getattr(self.handle, '_factory', False) and is_handle(result):
216
+ self.handle = result
217
+ start = getattr(result, 'start', None)
218
+ self.startargs = getargs(start, self.handleargs)
219
+ if start: start(**self.startargs)
220
+ @property
221
+ def priority(self):
222
+ return getattr(self.handle, 'priority', 0)
223
+ @priority.setter
224
+ def priority(self, value):
225
+ self.handle.priority = value
226
+ def func(self, key: str):
227
+ fu = getattr(self.handle, key, None)
228
+ method_cache_attr = f"_{key}_method"
229
+ args_cache_attr = f"{key}args"
230
+ if fu != getattr(self, method_cache_attr, None):
231
+ setattr(self, method_cache_attr, fu)
232
+ if fu is not None:
233
+ ar = getargs(fu, self.handleargs)
234
+ setattr(self, args_cache_attr, ar)
235
+ else:
236
+ if hasattr(self, args_cache_attr):
237
+ delattr(self, args_cache_attr)
238
+ if fu:
239
+ return lambda: fu(**getattr(self, args_cache_attr))
240
+ return None
241
+ def cancel(self):
242
+ self.world.handles = [h for h in self.world.handles if h is not self]
243
+
244
+ def ordered_handles(self):
245
+ return sorted(self.handles, key=lambda h: -h.priority)
246
+
247
+ def launch(self, handle=None, safe=True, state='start'):
248
+ if handle is None:
249
+ return lambda h: self.launch(h, safe=safe, state=state)
250
+ cls = handle_class(handle, state)
251
+ return (attempt(lambda: self.Handle(cls, self))
252
+ if safe else self.Handle(cls, self))
253
+
254
+ def cancel(self, handle):
255
+ self.handles = [h for h in self.handles if h is not handle]
256
+
257
+ def register(self, front=True):
258
+ if self.session is not None:
259
+ if self not in self.session.worlds:
260
+ self.session.worlds.append(self)
261
+ self.sync_colors()
262
+ if front: self.front()
263
+ else: self.back()
264
+ self.sort()
265
+ self.session.latency = self.session.latency
266
+ with self.session._resume:
267
+ self.session._resume.notify_all()
268
+
269
+ def sync_colors(self):
270
+ if self.session is not None:
271
+ self.scene.bgcolor = self.session.bgcolor
272
+ self.scene.fgcolor = self.session.fgcolor
273
+
274
+ def unregister(self):
275
+ if self.session is not None:
276
+ self.session.worlds = [w for w in self.session.worlds if w is not self]
277
+
278
+ def front(self):
279
+ if self.session is not None:
280
+ ll = None
281
+ l = 0
282
+ for w in self.session.worlds:
283
+ if ll is None:
284
+ ll = w._layer
285
+ l = w._layer
286
+ continue
287
+ if w._layer > ll: l = w._layer
288
+ self.layer = l + 1
289
+
290
+ def back(self):
291
+ if self.session is not None:
292
+ ll = None
293
+ l = 0
294
+ for w in self.session.worlds:
295
+ if ll is None:
296
+ ll = w._layer
297
+ l = w._layer
298
+ continue
299
+ if w._layer < ll: l = w._layer
300
+ self.layer = l - 1
301
+
302
+ def tick_ready(self):
303
+ now = time.perf_counter()
304
+ interval = 1.0 / self.scene.framerate
305
+ if now - self.last_tick >= interval:
306
+ self.last_tick = max(self.last_tick + interval, now - interval)
307
+ return True
308
+ return False
309
+
310
+ #SESSION CLASS
311
+ class Session():
312
+ APIS = {'inp': True, 'style': True, 'sound': True}
313
+ LEGACY = ('termxoffset', 'termyoffset', 'termwidth', 'termheight')
314
+ SETTINGS = {'pausephysics': False, 'pauserendering': False, 'optimize': True, 'mouse': True, 'vsync': True, 'refresh': None,
315
+ 'autoterm': True, 'autodim': True, 'autobg': True, 'outerbg': True, 'clearonstop': True, 'autofps': True,
316
+ 'region': None,
317
+ 'fullscreen': None, 'hidecursor': None, 'no_color': None}
318
+
319
+ def __init__(self, watchfocus=True, trackmotion=True, ffmpeg=None, **kwargs):
320
+ unknown = [k for k in kwargs if k not in self.APIS and k not in self.SETTINGS and k not in self.LEGACY]
321
+ if unknown:
322
+ raise TypeError(f"Session() got unexpected keyword argument(s): {', '.join(unknown)}")
323
+ self.worlds = []
324
+ self.simulation = None
325
+ self.World = lambda front=True, **kwargs: World(session=self, autoreg=True, front=front, **kwargs)
326
+ self.term = Terminal()
327
+ self.apis = {name: kwargs.get(name, default) for name, default in self.APIS.items()}
328
+ self.inp = inp(term=self.term, watchfocus=watchfocus, trackmotion=trackmotion) if self.apis['inp'] else None
329
+ if ffmpeg:
330
+ set_ffmpeg(ffmpeg)
331
+ self.sound = Mixer(listener=self._listener) if self.apis['sound'] else None
332
+ self._compositor = Compositor()
333
+ self.running = False
334
+ self._pause = False
335
+ self._dinfo = None
336
+ self._debug = None
337
+ self._sync_available = None
338
+ self._latency = measure_latency()
339
+ self._idle_scene = scene.Scene() if self.apis['style'] else None
340
+ bg = self.term.get_bgcolor(bits=8)
341
+ self._bgcolor = bg if bg != (-1, -1, -1) else (0, 0, 0)
342
+ fg = self.term.get_fgcolor(bits=8)
343
+ self._fgcolor = fg if fg != (-1, -1, -1) else (204, 204, 204)
344
+ self._sync_colors()
345
+ self._resume = threading.Condition()
346
+ self._region = regions.Region(0, 0, None, None)
347
+ self._anchor_row = None
348
+ for name, default in self.SETTINGS.items():
349
+ setattr(self, name, kwargs.get(name, default))
350
+ for name in self.LEGACY:
351
+ if name in kwargs:
352
+ setattr(self, name, kwargs[name])
353
+ if self._idle_scene is not None:
354
+ self._fit(self._idle_scene)
355
+
356
+ def _layout(self, sc, world, w, h):
357
+ termx, termy = (w, h) if self.autoterm and (world is None or world.autoterm) else (sc.termx, sc.termy)
358
+ dimx, dimy, dimxoffset, dimyoffset = sc.dimx, sc.dimy, sc.dimxoffset, sc.dimyoffset
359
+ if self.autodim and (world is None or world.autodim):
360
+ spec = None if world is None else world.region
361
+ if spec is None:
362
+ dimx, dimy = termx, termy
363
+ if world is not None and world._placed:
364
+ dimxoffset = dimyoffset = 0
365
+ else:
366
+ x, y, dimx, dimy = regions.resolve(spec, termx, termy)
367
+ dimxoffset, dimyoffset = x, -y
368
+ return termx, termy, dimx, dimy, dimxoffset, dimyoffset
369
+
370
+ def _fit(self, sc, world=None, region=None):
371
+ if region is None:
372
+ region = self._resolve_region()
373
+ sc.termx, sc.termy, sc.dimx, sc.dimy, sc.dimxoffset, sc.dimyoffset = self._layout(sc, world, region[2], region[3])
374
+ if world is not None and world.region is None:
375
+ world._placed = False
376
+
377
+ @property
378
+ def bgcolor(self):
379
+ return self._bgcolor
380
+ @bgcolor.setter
381
+ def bgcolor(self, value):
382
+ self._bgcolor = value
383
+ self._sync_colors()
384
+
385
+ @property
386
+ def fgcolor(self):
387
+ return self._fgcolor
388
+ @fgcolor.setter
389
+ def fgcolor(self, value):
390
+ self._fgcolor = value
391
+ self._sync_colors()
392
+
393
+ def _sync_colors(self):
394
+ for w in self.worlds:
395
+ w.sync_colors()
396
+ if self._idle_scene is not None:
397
+ self._idle_scene.bgcolor = self._bgcolor
398
+ self._idle_scene.fgcolor = self._fgcolor
399
+
400
+ @property
401
+ def dinfo(self):
402
+ return self._dinfo
403
+ @dinfo.setter
404
+ def dinfo(self, value):
405
+ self._dinfo = value
406
+ self._debug_push()
407
+
408
+ def debug(self, title=None, back=False):
409
+ if self._debug is not None:
410
+ return True
411
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
412
+ try:
413
+ server.bind(('127.0.0.1', 0))
414
+ server.listen(1)
415
+ opening = ((ansi.osc('0;' + title) if title is not None else '')
416
+ + ansi.mode(1049, False) + ansi.mode(1049, True) + ansi.mode(25, False) + ansi.CLEAR + ansi.HOME)
417
+ script = (DEBUG_RECEIVER
418
+ .replace('__OPEN__', repr(opening))
419
+ .replace('__HOLD__', repr(DEBUG_HOLD))
420
+ .replace('__CLOSE__', repr(ansi.mode(25, True)))
421
+ .replace('__PORT__', str(server.getsockname()[1]))
422
+ .replace('__BACK__', repr(bool(back))))
423
+ proc = spawn_console(script)
424
+ if proc is None:
425
+ return False
426
+ server.settimeout(5)
427
+ try:
428
+ conn, _ = server.accept()
429
+ except socket.timeout:
430
+ try: proc.kill()
431
+ except Exception: pass
432
+ return False
433
+ finally:
434
+ server.close()
435
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
436
+ conn.settimeout(1.0)
437
+ try: sys.stdout.reconfigure(encoding='utf-8')
438
+ except Exception: pass
439
+ self._debug = {'proc': proc, 'conn': conn, 'last': None, 'hold': False}
440
+ if self.inp and self.inp.wf:
441
+ self.inp.focused = False
442
+ self._debug_push()
443
+ return True
444
+
445
+ def undebug(self):
446
+ d, self._debug = self._debug, None
447
+ if d is None:
448
+ return
449
+ try: d['conn'].close()
450
+ except OSError: pass
451
+ if sys.platform != 'darwin' and not d['hold']:
452
+ try: d['proc'].kill()
453
+ except Exception: pass
454
+ if self.inp and self.inp.wf:
455
+ self.inp.focused = True
456
+
457
+ def _debug_send(self, frame):
458
+ try:
459
+ self._debug['conn'].sendall(frame.encode('utf-8'))
460
+ return True
461
+ except (OSError, ValueError):
462
+ self.undebug()
463
+ return False
464
+
465
+ def _debug_push(self):
466
+ d = self._debug
467
+ if d is None:
468
+ return
469
+ frame = format_dinfo(self._dinfo)
470
+ if frame != d['last'] and self._debug_send(frame):
471
+ d['last'] = frame
472
+
473
+ def _debug_trace(self):
474
+ d = self._debug
475
+ if d is None:
476
+ return
477
+ d['hold'] = True
478
+ self._debug_send(ansi.CLEAR_ALL + highlight_traceback(traceback.format_exc()) + DEBUG_HOLD)
479
+
480
+ def _listener(self):
481
+ if not self.worlds:
482
+ return None
483
+ sc = self.worlds[-1].scene
484
+ return (sc.camx + sc.dimx / 2.0, sc.camy - sc.dimy / 2.0)
485
+
486
+ @property
487
+ def styler(self):
488
+ if not self.apis['style']:
489
+ return None
490
+ return (self.worlds[-1].scene if self.worlds else self._idle_scene).styler
491
+
492
+ @property
493
+ def pause(self):
494
+ return self._pause
495
+ @pause.setter
496
+ def pause(self, value):
497
+ self._pause = value
498
+ if not value:
499
+ with self._resume:
500
+ self._resume.notify_all()
501
+
502
+ @property
503
+ def latency(self):
504
+ return self._latency
505
+ @latency.setter
506
+ def latency(self, value):
507
+ self._latency = value
508
+ if self.autofps:
509
+ for w in self.worlds:
510
+ if w.autofps: w.scene.maxframerate = 1 / max(value, 1e-6)
511
+
512
+ @property
513
+ def region(self):
514
+ return self._region
515
+ @region.setter
516
+ def region(self, value):
517
+ self._region = regions.parse(value)
518
+ if not self.running:
519
+ self._anchor_row = None
520
+
521
+ def _legacy_region(index):
522
+ def get(self):
523
+ return self._region[index]
524
+ def set(self, value):
525
+ parts = list(self._region)
526
+ parts[index] = value
527
+ self.region = parts
528
+ return property(get, set)
529
+
530
+ termxoffset = _legacy_region(0)
531
+ termyoffset = _legacy_region(1)
532
+ termwidth = _legacy_region(2)
533
+ termheight = _legacy_region(3)
534
+ del _legacy_region
535
+
536
+ @property
537
+ def resolved_region(self):
538
+ return self._resolve_region()
539
+
540
+ def _resolve_region(self):
541
+ return regions.resolve(self._region, self.term.width, self.term.height, self._anchor_row or 0)
542
+
543
+ def _anchor(self):
544
+ self._anchor_row = None
545
+ if not regions.uses_cursor(self._region):
546
+ return
547
+ tw, th = self.term.width, self.term.height
548
+ want = 1 if self._region.height is None else max(1, regions.resolve(self._region._replace(y=0), tw, th).height)
549
+ want = min(want, th)
550
+ here = self.term.get_cursor()
551
+ if here is not None and here[1] > 0:
552
+ sys.stdout.write('\r\n')
553
+ sys.stdout.write('\n' * want)
554
+ sys.stdout.flush()
555
+ here = self.term.get_cursor()
556
+ self._anchor_row = max(0, th - want if here is None else here[0] - want)
557
+
558
+ def _release(self):
559
+ row = self._anchor_row
560
+ if row is None:
561
+ return
562
+ x, y, w, h = self._resolve_region()
563
+ last = max(row, min(self.term.height - 1, y + max(h, 1) - 1))
564
+ if self.clearonstop:
565
+ sys.stdout.write(ansi.move(row + 1, 1))
566
+ else:
567
+ sys.stdout.write(ansi.move(last + 1, 1) + '\r\n')
568
+ sys.stdout.flush()
569
+ self._anchor_row = None
570
+
571
+ def _footprint(self, region):
572
+ x, y, w, h = region
573
+ if not self.worlds:
574
+ return region
575
+ right = bottom = 0
576
+ for world in self.worlds:
577
+ sc = world.scene
578
+ termx, termy, dimx, dimy, dimxoffset, dimyoffset = self._layout(sc, world, w, h)
579
+ view = Compositor.view(SimpleNamespace(
580
+ termx=termx, termy=termy, dimx=dimx, dimy=dimy,
581
+ dimxoffset=dimxoffset, dimyoffset=dimyoffset,
582
+ ), w, h)
583
+ right = max(right, view[2])
584
+ bottom = max(bottom, view[3])
585
+ return (x, y, right, bottom)
586
+
587
+ def _vsync_available(self):
588
+ if self._sync_available is None:
589
+ self._sync_available = self.term.get_dec_mode(MODE_SYNC) in (1, 2)
590
+ return self._sync_available
591
+
592
+ def _partial(self):
593
+ if not regions.is_default(self._region):
594
+ return True
595
+ full = self._resolve_region()
596
+ return self._footprint(full) != full
597
+
598
+ def style(self, *args, **kwargs):
599
+ if not self.apis['style']:
600
+ raise RuntimeError("the style API is disabled for this Session (Session(style=False))")
601
+ return self.styler.style(*args, **kwargs)
602
+
603
+ def run_attempt(self, **kwargs):
604
+ if(not self.running):
605
+ self.running = True
606
+ for setting, val in kwargs.items():
607
+ if val is not None: setattr(self, setting, val)
608
+ self._anchor()
609
+ if self._idle_scene is not None:
610
+ self._fit(self._idle_scene)
611
+ for world in self.worlds:
612
+ self._fit(world.scene, world)
613
+ with contextlib.ExitStack() as main_stack:
614
+ opt_stack = main_stack.enter_context(contextlib.ExitStack())
615
+ is_optimized = False
616
+ changed_optimized = False
617
+ optt_stack = main_stack.enter_context(contextlib.ExitStack())
618
+ is_mouse = False
619
+
620
+ fs = hc = not self._partial()
621
+ while self.running:
622
+ if changed_optimized: changed_optimized = False
623
+ if self.optimize and not is_optimized:
624
+ fs = self.fullscreen if self.fullscreen is not None else not self._partial()
625
+ hc = self.hidecursor if self.hidecursor is not None else True
626
+ if fs: opt_stack.enter_context(self.term.fullscreen())
627
+ opt_stack.enter_context(self.term.cbreak())
628
+ if hc: opt_stack.enter_context(self.term.hidden_cursor())
629
+ opt_stack.enter_context(self.term.no_line_wrap())
630
+ opt_stack.enter_context(self.term.synchronized_output())
631
+ is_optimized = True
632
+ changed_optimized = True
633
+ if is_mouse:
634
+ optt_stack.close()
635
+ optt_stack = main_stack.enter_context(contextlib.ExitStack())
636
+ optt_stack.enter_context(
637
+ self.term.mouse_enabled(report_drag=True, report_motion=True)
638
+ )
639
+ elif not self.optimize and is_optimized:
640
+ opt_stack.close()
641
+ opt_stack = main_stack.enter_context(contextlib.ExitStack())
642
+ is_optimized = False
643
+ if self.mouse and not is_mouse:
644
+ optt_stack.enter_context(self.term.mouse_enabled(report_drag=True, report_motion=True))
645
+ is_mouse = True
646
+ elif not self.mouse and is_mouse:
647
+ optt_stack.close()
648
+ optt_stack = main_stack.enter_context(contextlib.ExitStack())
649
+ is_mouse = False
650
+
651
+ if(not self._pause):
652
+ if self.inp: self.inp.update()
653
+ framerate_groups = {}
654
+ for w in self.worlds:
655
+ if w.pause: continue
656
+ fps = w.scene.framerate
657
+ if fps not in framerate_groups:
658
+ framerate_groups[fps] = []
659
+ framerate_groups[fps].append(w)
660
+ ready_fps = set()
661
+ for fps, group in framerate_groups.items():
662
+ now = time.perf_counter()
663
+ if any(now - w.last_tick >= 1.0 / fps for w in group):
664
+ ready_fps.add(fps)
665
+
666
+ itworlds = [w for w in self.worlds if not w.pause and w.scene.framerate in ready_fps and w.tick_ready()]
667
+
668
+ region = self._resolve_region()
669
+ for idx, world in enumerate(itworlds):
670
+ self._fit(world.scene, world, region)
671
+
672
+ for handle in world.ordered_handles():
673
+ if handle.pause: continue
674
+ update = handle.func('update')
675
+ if update:
676
+ update()
677
+
678
+ dim_changed = any([w.scene.checkres() for w in self.worlds if not w.pause]) or changed_optimized
679
+ if dim_changed:
680
+ self._compositor.invalidate()
681
+
682
+ for idx, world in enumerate(itworlds):
683
+ world.scene.tick(not (world.pausephysics or self.pausephysics))
684
+
685
+ for handle in world.ordered_handles():
686
+ if handle.pause: continue
687
+ final = handle.func('final')
688
+ if final:
689
+ final()
690
+
691
+ world.scene.flush()
692
+
693
+ region = self._footprint(self._resolve_region())
694
+ bounds = (self.term.width, self.term.height)
695
+ active = [] if self.pauserendering else [w for w in itworlds if not w.pauserendering]
696
+ self._compositor.no_color = self.no_color
697
+ self._compositor.vsync = self.vsync
698
+ self._compositor.refresh = self.refresh
699
+ frame = self._compositor.compose(
700
+ self.worlds, active, region, bounds,
701
+ self.bgcolor if self.outerbg else None,
702
+ self.autobg,
703
+ )
704
+ if frame:
705
+ if not (hc and fs): frame = ansi.wrap(ansi.SAVE_CURSOR) + frame + ansi.wrap(ansi.RESTORE_CURSOR)
706
+ if self.vsync and self._vsync_available(): frame = ansi.wrap(ansi.SYNC_BEGIN) + frame + ansi.wrap(ansi.SYNC_END)
707
+ self._compositor.vblank()
708
+ sys.stdout.write(frame)
709
+ sys.stdout.flush()
710
+
711
+ now = time.perf_counter()
712
+ next_tick = min(
713
+ (min(w.last_tick for w in group) + 1.0 / fps
714
+ for fps, group in framerate_groups.items()),
715
+ default=now + (1.0 / 60 if self.worlds else 0)
716
+ )
717
+ sleep = next_tick - now
718
+ if sleep > self._latency and not self._pause:
719
+ tar = time.perf_counter() + sleep
720
+ coarse = sleep - _SPIN_MARGIN
721
+ if coarse > 0: time.sleep(coarse)
722
+ while time.perf_counter() < tar: pass
723
+ elif self._pause or len(self.worlds) == 0:
724
+ with self._resume:
725
+ self._resume.wait_for(lambda: not self._pause and len(self.worlds) > 0)
726
+
727
+ if self.inp:
728
+ self.inp.stop_all()
729
+ self.inp.cleanup()
730
+ if self.sound: self.sound.close()
731
+ if self.clearonstop:
732
+ if self._partial():
733
+ sys.stdout.write(ansi.wrap(ansi.SAVE_CURSOR) + Compositor.vacate(self._compositor.region, (-1, -1, 0, 0), (self.term.width, self.term.height)) + ansi.wrap(ansi.RESET) + ansi.wrap(ansi.RESTORE_CURSOR)
734
+ if self._compositor.region else '')
735
+ else:
736
+ sys.stdout.write(ansi.wrap_all(ansi.CLEAR_ALL))
737
+ sys.stdout.flush()
738
+ self._release()
739
+ self.undebug()
740
+
741
+ def run(self, **kwargs):
742
+ attempt(lambda: self.run_attempt(**kwargs), self._debug_trace)
743
+
744
+ def stop(self):
745
+ self.running = False
746
+ with self._resume:
747
+ self._resume.notify_all()
748
+
749
+ class Simulation():
750
+ def __init__(self, *sessions):
751
+ self.sessions = []
752
+ for session in sessions:
753
+ self.add(session)
754
+
755
+ @property
756
+ def worlds(self):
757
+ return [w for s in self.sessions for w in s.worlds]
758
+
759
+ @property
760
+ def handles(self):
761
+ return [h for w in self.worlds for h in w.handles]
762
+
763
+ @property
764
+ def scenes(self):
765
+ return [w.scene for w in self.worlds]
766
+
767
+ def __getitem__(self, index):
768
+ return self.sessions[index]
769
+
770
+ def __len__(self):
771
+ return len(self.sessions)
772
+
773
+ def __iter__(self):
774
+ return iter(self.sessions)
775
+
776
+ def add(self, session):
777
+ session.simulation = self
778
+ if session not in self.sessions:
779
+ self.sessions.append(session)
780
+ return session
781
+
782
+ def session(self, **kwargs):
783
+ return self.add(Session(**kwargs))
784
+
785
+ def _session(self, ref=None):
786
+ if ref is None:
787
+ return self.sessions[-1] if self.sessions else self.session()
788
+ if isinstance(ref, Session):
789
+ return self.add(ref)
790
+ return self.sessions[ref]
791
+
792
+ def world(self, session=None, front=True, **kwargs):
793
+ return self._session(session).World(front=front, **kwargs)
794
+
795
+ def _world(self, ref=None, session=None):
796
+ if isinstance(ref, World):
797
+ return ref
798
+ target = self._session(session)
799
+ if ref is None:
800
+ return target.worlds[-1] if target.worlds else target.World()
801
+ return target.worlds[ref]
802
+
803
+ def launch(self, *handles, world=None, session=None, safe=True, state='start'):
804
+ target = self._world(world, session)
805
+ return [target.launch(handle, safe=safe, state=state) for handle in handles]
806
+
807
+ def run(self, session=None, **settings):
808
+ self._session(session).run(**settings)
809
+ return self
810
+
811
+ def stop(self):
812
+ for session in self.sessions:
813
+ session.running = False
814
+
815
+ def start(*launch, **settings):
816
+ simulation = Simulation()
817
+ simulation.session()
818
+ if launch:
819
+ simulation.world()
820
+ simulation.launch(*launch)
821
+ simulation.run(**settings)
822
+ return simulation