stackvm 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.
stackvm/_ui.py ADDED
@@ -0,0 +1,1036 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import itertools as it
5
+ import platform
6
+ from collections.abc import Callable
7
+ from concurrent.futures import Future
8
+ from dataclasses import dataclass
9
+ from functools import cache, partial
10
+ from importlib import metadata, resources
11
+ from io import IOBase, StringIO
12
+ from random import Random
13
+ from types import CoroutineType, NoneType, SimpleNamespace
14
+ from typing import Any, ClassVar, cast
15
+ from weakref import WeakKeyDictionary
16
+
17
+ import flet as ft
18
+
19
+ from stackvm._api import Executor, StackMachine
20
+ from stackvm._util import Instruction as VMInstruction
21
+ from stackvm._util import StackItem, VMFrame
22
+
23
+
24
+ @cache
25
+ def monospace(size: int = 24) -> ft.TextStyle:
26
+ return ft.TextStyle(
27
+ size=size,
28
+ font_family="JetBrains Mono",
29
+ font_family_fallback=["Fira Code", "Consolas", "Inconsolata", "SF Mono", "Monospace"],
30
+ )
31
+
32
+
33
+ COLORS: dict[type, ft.Colors] = {
34
+ NoneType: ft.Colors.GREY_600,
35
+ bool: ft.Colors.BLUE_600,
36
+ int: ft.Colors.GREEN_600,
37
+ float: ft.Colors.ORANGE_600,
38
+ str: ft.Colors.PURPLE_600,
39
+ bytes: ft.Colors.BROWN_600,
40
+ slice: ft.Colors.CYAN_600,
41
+ list: ft.Colors.RED_600,
42
+ dict: ft.Colors.YELLOW_600,
43
+ }
44
+
45
+
46
+ async def loop(
47
+ page: ft.Page,
48
+ executor: Executor,
49
+ pause: asyncio.Event,
50
+ send: asyncio.Queue[tuple[bool, Any]],
51
+ receive: asyncio.Queue[float],
52
+ ) -> object:
53
+ await send.put((False, -1))
54
+ delay = await receive.get()
55
+ await asyncio.sleep(delay)
56
+ while True:
57
+ await pause.wait()
58
+ current = executor.state.frames[-1].current
59
+
60
+ try:
61
+ returned, value = await asyncio.create_task(asyncio.to_thread(executor.step))
62
+ except BaseException as e: # noqa: BLE001
63
+ await send.put((True, (current, e)))
64
+ break
65
+
66
+ if returned:
67
+ await send.put((True, (current, value)))
68
+ return
69
+
70
+ await send.put((False, current))
71
+ delay = await receive.get()
72
+
73
+ if pause.is_set(): # spam clicking step is okay
74
+ await asyncio.sleep(delay)
75
+
76
+
77
+ ITEM_HEIGHT = 50
78
+
79
+
80
+ def item_height(obj: object) -> float:
81
+ if isinstance(obj, list):
82
+ return ITEM_HEIGHT + sum(item_height(i) for i in obj)
83
+
84
+ if isinstance(obj, dict):
85
+ return ITEM_HEIGHT + sum(max(item_height(k), item_height(v)) for k, v in obj.items())
86
+
87
+ return ITEM_HEIGHT
88
+
89
+
90
+ @ft.control
91
+ class Item(ft.Container):
92
+ @property
93
+ def item(self) -> object:
94
+ return self.data
95
+
96
+ @property
97
+ def app(self) -> App:
98
+ try:
99
+ return self.page.data
100
+ except RuntimeError:
101
+ return ft.context.page.data
102
+
103
+ def init(self):
104
+ obj = self.item
105
+ color = COLORS.get(type(obj), ft.Colors.PINK_600)
106
+
107
+ self.bgcolor = color
108
+ self.border = ft.Border.all(2, ft.Colors.BLACK)
109
+
110
+ self.height = item_height(obj)
111
+ self.border_radius = ITEM_HEIGHT / 5
112
+
113
+ def before_update(self):
114
+ obj = self.item
115
+ size = 24
116
+
117
+ if isinstance(obj, IOBase):
118
+ obj_repr = f"File: {getattr(obj, 'name', '<unknown>')}"
119
+ elif isinstance(obj, list):
120
+ obj_repr = "List: []" if not obj else "List:"
121
+ elif isinstance(obj, dict):
122
+ obj_repr = "Dictionary: {}" if not obj else "Dictionary:"
123
+ else:
124
+ obj_repr = repr(obj)
125
+
126
+ text = ft.Row(
127
+ [
128
+ ft.Text(
129
+ obj_repr,
130
+ style=monospace(size),
131
+ text_align=ft.TextAlign.CENTER,
132
+ align=ft.Alignment.CENTER,
133
+ color=ft.Colors.BLACK if isinstance(obj, dict) else ft.Colors.WHITE,
134
+ )
135
+ ],
136
+ scroll=ft.ScrollMode.AUTO,
137
+ spacing=0,
138
+ alignment=ft.MainAxisAlignment.CENTER,
139
+ )
140
+
141
+ if obj and isinstance(obj, list):
142
+ children = [Item(data=item) for item in obj]
143
+ self.content = ft.Column(
144
+ cast(
145
+ list[ft.Control],
146
+ [
147
+ text,
148
+ ft.Container(
149
+ ft.Column(cast(list[ft.Control], children), spacing=0),
150
+ padding=ft.Padding.symmetric(horizontal=4),
151
+ ),
152
+ ],
153
+ )
154
+ )
155
+ elif obj and isinstance(obj, dict):
156
+ children = [
157
+ ft.Row([Item(data=key, expand=1), Item(data=value, expand=1)], spacing=0) for key, value in obj.items()
158
+ ]
159
+ self.content = ft.Column(
160
+ cast(
161
+ list[ft.Control],
162
+ [
163
+ text,
164
+ ft.Container(
165
+ ft.Column(cast(list[ft.Control], children), spacing=0),
166
+ padding=ft.Padding.symmetric(horizontal=5),
167
+ ),
168
+ ],
169
+ )
170
+ )
171
+ else:
172
+ self.content = text
173
+
174
+ def build(self) -> None:
175
+ self.set_animation()
176
+
177
+ def set_animation(self) -> None:
178
+ self.animate = ft.Animation(int((self.app.state.delay * 1000) // 2), ft.AnimationCurve.EASE_IN_OUT)
179
+ self.animate_size = ft.Animation(int((self.app.state.delay * 1000) // 2), ft.AnimationCurve.EASE_IN_OUT)
180
+ self.animate_offset = ft.Animation(int((self.app.state.delay * 1000) // 2), ft.AnimationCurve.EASE_IN_OUT)
181
+
182
+
183
+ @ft.control
184
+ class Instruction(ft.Container):
185
+ @property
186
+ def index(self) -> VMInstruction:
187
+ return self.data.index
188
+
189
+ @property
190
+ def instruction(self) -> VMInstruction:
191
+ return self.data.instruction
192
+
193
+ @property
194
+ def current(self) -> bool:
195
+ return self.data.current
196
+
197
+ @current.setter
198
+ def current(self, value: bool) -> None:
199
+ self.data.current = value
200
+
201
+ @property
202
+ def text(self) -> str:
203
+ return self.data.text
204
+
205
+ @text.setter
206
+ def text(self, value: str) -> None:
207
+ self.data.text = value
208
+
209
+ @property
210
+ def app(self) -> App:
211
+ try:
212
+ return self.page.data
213
+ except RuntimeError:
214
+ return ft.context.page.data
215
+
216
+ def init(self):
217
+ self.data = SimpleNamespace(index=self.data[0], instruction=self.data[1], current=False)
218
+
219
+ instr = self.instruction
220
+ color = ft.Colors.WHITE
221
+
222
+ size = 24
223
+
224
+ self.text = f"{self.index}: {instr}"
225
+
226
+ control: list[ft.Control] = [
227
+ ft.Text(
228
+ self.text,
229
+ style=monospace(size),
230
+ text_align=ft.TextAlign.CENTER,
231
+ align=ft.Alignment.CENTER,
232
+ )
233
+ ]
234
+
235
+ self.content = ft.Row(
236
+ control,
237
+ scroll=ft.ScrollMode.AUTO,
238
+ spacing=0,
239
+ alignment=ft.MainAxisAlignment.CENTER,
240
+ )
241
+ self.bgcolor = color
242
+ self.border = ft.Border.all(2, ft.Colors.BLACK)
243
+
244
+ self.height = ITEM_HEIGHT
245
+ self.border_radius = ITEM_HEIGHT / 5
246
+
247
+ def before_update(self):
248
+ control = cast(ft.Text, cast(ft.Row, self.content).controls[0])
249
+ if self.current:
250
+ control.value = f"▶️ {self.text} ◀️"
251
+ else:
252
+ control.value = self.text
253
+
254
+ if self.current:
255
+ control.color = ft.Colors.WHITE
256
+ else:
257
+ control.color = ft.Colors.BLACK
258
+
259
+ self.animate = ft.Animation(int((self.app.state.delay * 1000) // 2), ft.AnimationCurve.EASE_IN_OUT)
260
+
261
+
262
+ @ft.control
263
+ class Stack(ft.Column):
264
+ @property
265
+ def items(self) -> list[StackItem]:
266
+ return self.data.items
267
+
268
+ @items.setter
269
+ def items(self, items: list[StackItem]) -> None:
270
+ self.data.last_items = self.items
271
+ self.data.items = list(items)
272
+
273
+ @property
274
+ def positions(self) -> dict[StackItem, float]:
275
+ return self.data.positions
276
+
277
+ @positions.setter
278
+ def positions(self, positions: dict[StackItem, float]) -> None:
279
+ self.data.positions = dict(positions.items())
280
+
281
+ @property
282
+ def last_items(self) -> list[StackItem]:
283
+ return self.data.last_items
284
+
285
+ @property
286
+ def item_controls(self) -> WeakKeyDictionary[StackItem, Item]:
287
+ return self.data.item_controls
288
+
289
+ @property
290
+ def app(self) -> App:
291
+ try:
292
+ return self.page.data
293
+ except RuntimeError:
294
+ return ft.context.page.data
295
+
296
+ def init(self):
297
+ self.expand = 1
298
+ self.scroll = ft.ScrollMode.AUTO
299
+
300
+ self.data = SimpleNamespace(items=[], last_items=[], positions={}, item_controls=WeakKeyDictionary())
301
+
302
+ def before_update(self):
303
+ old_items = list(reversed(self.last_items))
304
+ new_items = list(reversed(self.items))
305
+
306
+ old_positions = self.positions
307
+
308
+ for item in new_items:
309
+ if item not in self.item_controls:
310
+ self.item_controls[item] = Item(key=id(item), data=item.obj)
311
+
312
+ leaving = set(old_items) - set(new_items)
313
+ entering = set(new_items) - set(old_items)
314
+
315
+ old_controls = [
316
+ (item, self.item_controls[item]) for item in old_items + [i for i in new_items if i in entering]
317
+ ]
318
+ new_controls: list[tuple[StackItem, Item]] = [(item, self.item_controls[item]) for item in new_items]
319
+
320
+ temporary_positions = dict(
321
+ zip(
322
+ (item for item, _ in old_controls),
323
+ it.accumulate(
324
+ old_controls,
325
+ lambda prev, entry: prev + item_height(entry[0].obj) + self.spacing,
326
+ initial=0,
327
+ ),
328
+ )
329
+ )
330
+ new_positions = dict(
331
+ zip(
332
+ new_items,
333
+ it.accumulate(
334
+ new_items,
335
+ lambda prev, item: prev + item_height(item.obj) + self.spacing,
336
+ initial=0,
337
+ ),
338
+ )
339
+ )
340
+
341
+ if old_positions == new_positions:
342
+ # currently impossible for a mutable object to be mutated without affecting the stack
343
+ return
344
+
345
+ self.positions = new_positions
346
+
347
+ positions: list[tuple[Item, tuple[tuple[float, float], tuple[float, float]]]] = []
348
+ for item, control in old_controls:
349
+ if item in leaving:
350
+ end = (-2, 0)
351
+ else:
352
+ height = item_height(item.obj)
353
+ diff = new_positions[item] - temporary_positions[item]
354
+ end = (0, diff / height)
355
+
356
+ start = (-2, end[1]) if item in entering else (0, 0)
357
+
358
+ positions.append((control, (start, end)))
359
+
360
+ async def animate():
361
+ await asyncio.sleep(1 / 30)
362
+ for item, control in new_controls:
363
+ control.height = item_height(item.obj)
364
+ control.update()
365
+ for control, (_, end) in positions:
366
+ control.offset = ft.Offset(*end)
367
+ control.update()
368
+ await asyncio.sleep(self.app.state.delay / 2)
369
+ self.controls = [entry[1] for entry in new_controls]
370
+ for control in cast(list[Item], self.controls):
371
+ control.offset = ft.Offset(0, 0)
372
+ control.animate_offset = None
373
+ self.data.last_items = list(self.items)
374
+ self.update()
375
+
376
+ self.controls = [entry[1] for entry in old_controls]
377
+
378
+ for control, (start, _) in positions:
379
+ control.offset = ft.Offset(*start)
380
+ control.set_animation()
381
+
382
+ self.app.create_task(animate)
383
+
384
+
385
+ @ft.control
386
+ class Instructions(ft.Column):
387
+ @property
388
+ def instructions(self) -> tuple[VMInstruction, ...]:
389
+ return self.data.instructions
390
+
391
+ @property
392
+ def current(self) -> int:
393
+ return self.data.current
394
+
395
+ @current.setter
396
+ def current(self, value: int) -> None:
397
+ self.data.prev = self.current
398
+ self.data.current = value
399
+
400
+ @property
401
+ def next(self) -> int:
402
+ return self.data.next
403
+
404
+ @next.setter
405
+ def next(self, value: int) -> None:
406
+ self.data.next = value
407
+
408
+ @property
409
+ def suspended(self) -> list[int]:
410
+ return self.data.suspended
411
+
412
+ @suspended.setter
413
+ def suspended(self, value: list[int]) -> None:
414
+ self.data.suspended = value
415
+
416
+ @property
417
+ def viewport_height(self) -> float:
418
+ return self.data.viewport_height
419
+
420
+ @viewport_height.setter
421
+ def viewport_height(self, value: float) -> None:
422
+ self.data.viewport_height = value
423
+
424
+ @property
425
+ def prev(self) -> int:
426
+ return self.data.prev
427
+
428
+ @property
429
+ def app(self) -> App:
430
+ try:
431
+ return self.page.data
432
+ except RuntimeError:
433
+ return ft.context.page.data
434
+
435
+ def init(self):
436
+ self.expand = 1
437
+ self.scroll = ft.ScrollMode.AUTO
438
+
439
+ self.data.current = 0
440
+ self.data.next = 0
441
+ self.data.prev = 0
442
+ self.data.suspended = []
443
+ self.data.viewport_height = 0
444
+
445
+ self.controls = [
446
+ Instruction(data=(index, instr), key=ft.ScrollKey(f"instr_{index}"))
447
+ for index, instr in enumerate(self.instructions)
448
+ ]
449
+
450
+ self.offsets = list(
451
+ it.accumulate(
452
+ self.controls,
453
+ lambda prev, control: prev + cast(float, cast(Instruction, control).height) + self.spacing,
454
+ initial=0,
455
+ )
456
+ )
457
+
458
+ def on_size_change(event: ft.LayoutSizeChangeEvent) -> None:
459
+ self.viewport_height = event.height
460
+
461
+ self.on_size_change = on_size_change
462
+
463
+ def before_update(self) -> None:
464
+ if 0 <= self.prev < len(self.controls):
465
+ prev = self.prev
466
+ control = cast(Instruction, self.controls[prev])
467
+ control.current = False
468
+ control.bgcolor = ft.Colors.WHITE
469
+
470
+ if 0 <= self.next <= len(self.controls):
471
+ control = cast(Instruction, self.controls[self.next])
472
+ control.current = False
473
+ control.bgcolor = ft.Colors.GREEN_200
474
+
475
+ for suspended in self.suspended:
476
+ if 0 <= suspended <= len(self.controls):
477
+ control = cast(Instruction, self.controls[suspended])
478
+ control.current = False
479
+ control.bgcolor = ft.Colors.GREY_400
480
+
481
+ if 0 <= self.current < len(self.controls):
482
+ current = self.current
483
+ control = cast(Instruction, self.controls[current])
484
+ control.current = True
485
+ control.bgcolor = ft.Colors.GREEN_800
486
+
487
+ async def scroll(current: int = current):
488
+ height = self.viewport_height
489
+ await self.scroll_to(
490
+ max(self.offsets[current] - height / 2 + (0 if control.height is None else control.height / 2), 0),
491
+ duration=int(self.app.state.delay * 1000 // 2),
492
+ )
493
+
494
+ self.app.create_task(scroll)
495
+
496
+
497
+ @ft.control
498
+ class Frame(ft.Container):
499
+ @property
500
+ def frame(self) -> VMFrame:
501
+ return self.data.frame
502
+
503
+ @property
504
+ def active(self) -> bool:
505
+ return self.data.active
506
+
507
+ @active.setter
508
+ def active(self, value: bool) -> None:
509
+ self.data.active = value
510
+
511
+ @property
512
+ def app(self) -> App:
513
+ try:
514
+ return self.page.data
515
+ except RuntimeError:
516
+ return ft.context.page.data
517
+
518
+ def init(self):
519
+ self.border = ft.Border.all(2, ft.Colors.BLACK)
520
+
521
+ self.border_radius = ITEM_HEIGHT / 5
522
+
523
+ self.expand = True
524
+
525
+ self.data = SimpleNamespace(frame=self.data[0], active=self.data[1])
526
+
527
+ self.set_animation()
528
+
529
+ def before_update(self):
530
+ self.set_animation()
531
+
532
+ color = ft.Colors.GREEN_200 if self.active else ft.Colors.GREY_200
533
+ grid_color = ft.Colors.GREEN_400 if self.active else ft.Colors.GREY_400
534
+
535
+ self.bgcolor = color
536
+
537
+ frame = self.frame
538
+
539
+ size = 24
540
+
541
+ text = f"Current: {frame.prev}\nNext: {frame.current}"
542
+
543
+ content = ft.Column(
544
+ [
545
+ ft.Text(
546
+ text,
547
+ style=monospace(size),
548
+ expand=True,
549
+ text_align=ft.TextAlign.CENTER,
550
+ )
551
+ ],
552
+ margin=ft.Margin.all(5),
553
+ horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
554
+ )
555
+
556
+ if frame.env:
557
+ grid = ft.Container(
558
+ ft.DataTable(
559
+ columns=[
560
+ ft.DataColumn(label=ft.Text("Variable", style=monospace(size))),
561
+ ft.DataColumn(label=ft.Text("Value", style=monospace(size))),
562
+ ],
563
+ rows=[
564
+ ft.DataRow(
565
+ cells=[
566
+ ft.DataCell(
567
+ ft.Text(
568
+ spans=[ft.TextSpan(str(key))]
569
+ + (
570
+ [ft.TextSpan(" (global)", style=monospace(size // 2))]
571
+ if key in frame.env.maps[1] and key not in frame.env.maps[0]
572
+ else []
573
+ ),
574
+ style=monospace(size),
575
+ )
576
+ ),
577
+ ft.DataCell(
578
+ ft.Row(
579
+ [
580
+ ft.Text(
581
+ repr(value),
582
+ style=monospace(size),
583
+ no_wrap=True,
584
+ )
585
+ ],
586
+ scroll=ft.ScrollMode.AUTO,
587
+ ),
588
+ ),
589
+ ]
590
+ )
591
+ for key, value in sorted(frame.env.items())
592
+ ],
593
+ bgcolor=grid_color,
594
+ border=ft.Border.all(2, ft.Colors.BLACK),
595
+ border_radius=ITEM_HEIGHT / 5,
596
+ horizontal_lines=ft.BorderSide(1, ft.Colors.BLACK),
597
+ vertical_lines=ft.BorderSide(1, ft.Colors.BLACK),
598
+ horizontal_margin=4,
599
+ column_spacing=8,
600
+ ),
601
+ theme=ft.Theme(divider_theme=ft.DividerTheme(color=ft.Colors.BLACK, thickness=1)),
602
+ )
603
+ content.controls.append(grid)
604
+
605
+ self.content = content
606
+
607
+ def set_animation(self) -> None:
608
+ pass
609
+
610
+
611
+ @ft.control
612
+ class CallStack(ft.Column):
613
+ @property
614
+ def executor(self) -> Executor:
615
+ return self.data.executor
616
+
617
+ @property
618
+ def frames(self) -> list[VMFrame]:
619
+ return self.executor.state.frames
620
+
621
+ @property
622
+ def frame_controls(self) -> dict[VMFrame, Frame]:
623
+ return self.data.frame_controls
624
+
625
+ @frame_controls.setter
626
+ def frame_controls(self, value: dict[VMFrame, Frame]) -> None:
627
+ self.data.frame_controls = value
628
+
629
+ def init(self):
630
+ self.expand = 2
631
+ self.horizontal_alignment = ft.CrossAxisAlignment.STRETCH
632
+ self.scroll = ft.ScrollMode.AUTO
633
+
634
+ self.frame_controls = {}
635
+
636
+ def before_update(self):
637
+ controls: list[ft.Control] = []
638
+
639
+ frames = list(reversed(self.frames))
640
+
641
+ if not frames:
642
+ return
643
+
644
+ if frames[0] in self.frame_controls:
645
+ control = self.frame_controls[frames[0]]
646
+ control.active = True
647
+ controls.append(control)
648
+ else:
649
+ control = Frame(data=(frames[0], True))
650
+ self.frame_controls[frames[0]] = control
651
+ controls.append(control)
652
+
653
+ for frame in frames[1:]:
654
+ control = self.frame_controls[frame]
655
+ control.active = False
656
+ controls.append(control)
657
+
658
+ for frame in self.frame_controls.keys() - set(frames):
659
+ del self.frame_controls[frame]
660
+
661
+ self.controls = controls
662
+
663
+
664
+ @ft.control
665
+ class Console(ft.Container):
666
+ @property
667
+ def stdout(self) -> StringIO:
668
+ return self.data
669
+
670
+ @property
671
+ def text(self) -> ft.Text:
672
+ return cast(ft.Text, cast(ft.Column, self.content).controls[0])
673
+
674
+ def init(self) -> None:
675
+ self.bgcolor = ft.Colors.GREEN_900
676
+
677
+ self.border = ft.Border.all(2, ft.Colors.BLACK)
678
+ self.border_radius = ITEM_HEIGHT // 5
679
+
680
+ self.expand = 1
681
+
682
+ self.content = ft.Column(
683
+ [
684
+ ft.Text(
685
+ style=monospace(16),
686
+ color=ft.Colors.WHITE,
687
+ margin=ft.Margin.all(5),
688
+ selectable=True,
689
+ )
690
+ ],
691
+ scroll=ft.ScrollMode.AUTO,
692
+ auto_scroll=True,
693
+ )
694
+
695
+ def before_update(self) -> None:
696
+ self.text.value = self.stdout.getvalue()
697
+
698
+
699
+ def ErrorDialog(page: ft.Page, exception: BaseException) -> ft.AlertDialog:
700
+ def close() -> None:
701
+ page.pop_dialog()
702
+
703
+ return ft.AlertDialog(
704
+ modal=True,
705
+ title=ft.Text("Exception raised during execution"),
706
+ content=ft.Text(f"{type(exception).__name__}: {exception}", style=monospace(14), selectable=True),
707
+ actions=[ft.TextButton("OK", on_click=close)],
708
+ )
709
+
710
+
711
+ def ReturnDialog(page: ft.Page, obj: object) -> ft.AlertDialog:
712
+ def close() -> None:
713
+ page.pop_dialog()
714
+
715
+ return ft.AlertDialog(
716
+ modal=True,
717
+ title=ft.Text("Program finished successfully"),
718
+ content=ft.Text(
719
+ spans=[ft.TextSpan("Returned: "), ft.TextSpan(repr(obj), style=monospace(14))], selectable=True
720
+ ),
721
+ actions=[ft.TextButton("OK", on_click=close)],
722
+ )
723
+
724
+
725
+ async def input_dialog(page: ft.Page, stdout: StringIO) -> str:
726
+ future: asyncio.Future[str] = asyncio.Future()
727
+
728
+ def submit(event: ft.Event[Any]):
729
+ value = text_input.value or ""
730
+ print(value, file=stdout)
731
+ page.pop_dialog()
732
+ if not future.done():
733
+ future.set_result(value)
734
+
735
+ text_input = ft.TextField(autofocus=True, on_submit=submit)
736
+
737
+ dialog = ft.AlertDialog(
738
+ modal=True,
739
+ title=ft.Text("Input requested"),
740
+ content=text_input,
741
+ actions=cast(list[ft.Control], [ft.TextButton("Submit", on_click=submit)]),
742
+ )
743
+
744
+ page.show_dialog(dialog)
745
+
746
+ return await future
747
+
748
+
749
+ def get_input_from_dialog(page: ft.Page, stdout: StringIO) -> str:
750
+ result = asyncio.run_coroutine_threadsafe(input_dialog(page, stdout), page.loop)
751
+ return result.result()
752
+
753
+
754
+ @ft.control
755
+ class SpeedSlider(ft.Slider):
756
+ delays: ClassVar[tuple[float, ...]] = (1.25, 1.0, 0.75, 0.5, 0.25)
757
+
758
+ @property
759
+ def app(self) -> App:
760
+ try:
761
+ return self.page.data
762
+ except RuntimeError:
763
+ return ft.context.page.data
764
+
765
+ def init(self):
766
+ self.value = 2
767
+ self.min = 0
768
+ self.max = 4
769
+ self.divisions = 4
770
+
771
+ self.on_change = self.handle_change
772
+
773
+ self.expand = True
774
+
775
+ self.set_label()
776
+
777
+ def set_label(self) -> None:
778
+ value = 2 if self.value is None else int(self.value)
779
+ self.label = ["Slowest", "Slow", "Medium", "Fast", "Fastest"][value]
780
+
781
+ def handle_change(self, event: ft.Event[ft.Slider]):
782
+ value = 2 if self.value is None else int(self.value)
783
+ self.set_label()
784
+
785
+ self.app.state.delay = self.delays[value]
786
+
787
+
788
+ @ft.control
789
+ class PauseButton(ft.IconButton):
790
+ @property
791
+ def pause(self) -> asyncio.Event:
792
+ return self.data
793
+
794
+ def init(self) -> None:
795
+ self.icon = ft.Icons.PAUSE
796
+
797
+ def on_click(event: ft.Event[ft.IconButton]):
798
+ if self.pause.is_set():
799
+ self.pause.clear()
800
+ self.icon = ft.Icons.PLAY_ARROW
801
+ else:
802
+ self.pause.set()
803
+ self.icon = ft.Icons.PAUSE
804
+
805
+ self.on_click = on_click
806
+
807
+
808
+ @ft.control
809
+ class StepButton(ft.IconButton):
810
+ @property
811
+ def pause(self) -> asyncio.Event:
812
+ return self.data
813
+
814
+ def init(self) -> None:
815
+ self.icon = ft.Icons.SKIP_NEXT
816
+
817
+ async def on_click(event: ft.Event[ft.IconButton]):
818
+ if not self.pause.is_set():
819
+ self.pause.set()
820
+ await asyncio.sleep(0)
821
+ self.pause.clear()
822
+
823
+ self.on_click = on_click
824
+
825
+
826
+ @dataclass
827
+ class AppState:
828
+ delay: float = 0.75
829
+ paused: bool = False
830
+
831
+
832
+ class App:
833
+ page: ft.Page
834
+ machine: StackMachine
835
+ executor: Executor
836
+
837
+ speed_control: ft.Row
838
+ stack: Stack
839
+ instructions: Instructions
840
+ call_stack: CallStack
841
+ console: Console
842
+
843
+ stdout: StringIO
844
+
845
+ pause: asyncio.Event
846
+ send: asyncio.Queue[float]
847
+ receive: asyncio.Queue[tuple[bool, Any]]
848
+
849
+ state: AppState
850
+
851
+ tasks: set[Future]
852
+
853
+ result: Future[Any]
854
+
855
+ closing: bool
856
+
857
+ def __init__(
858
+ self,
859
+ machine: StackMachine,
860
+ page: ft.Page,
861
+ *,
862
+ args: tuple[Any, ...],
863
+ seed: int | str | Random | None = None,
864
+ result: Future[Any],
865
+ ) -> None:
866
+ self.tasks = set()
867
+
868
+ self.state = AppState()
869
+
870
+ self.page = page
871
+ page.data = self
872
+
873
+ page.title = "StackVM"
874
+
875
+ if platform.system() == "Windows":
876
+ icon = resources.files("stackvm").joinpath("resources", "icon.ico")
877
+
878
+ with resources.as_file(icon) as path:
879
+ page.window.icon = str(path)
880
+
881
+ self.machine = machine
882
+
883
+ self.stdout = StringIO()
884
+ self.executor = machine.ui_executor(
885
+ *args, seed=seed, stdin=partial(get_input_from_dialog, page, self.stdout), stdout=self.stdout
886
+ )
887
+
888
+ self.pause = asyncio.Event()
889
+ self.pause.set()
890
+
891
+ self.send = asyncio.Queue()
892
+ self.receive = asyncio.Queue()
893
+
894
+ self.speed_control = ft.Row(
895
+ [
896
+ ft.Text("Speed:", size=18),
897
+ SpeedSlider(0),
898
+ PauseButton(data=self.pause),
899
+ StepButton(data=self.pause),
900
+ ft.IconButton(ft.Icons.STOP, icon_color=ft.Colors.RED, on_click=page.window.close),
901
+ ],
902
+ )
903
+
904
+ self.stack = Stack()
905
+ self.instructions = Instructions(data=SimpleNamespace(instructions=self.executor.instructions))
906
+ self.call_stack = CallStack(data=SimpleNamespace(executor=self.executor))
907
+ self.console = Console(data=self.stdout)
908
+
909
+ page.theme = ft.Theme(color_scheme=ft.ColorScheme(primary=ft.Colors.GREEN))
910
+
911
+ self.closing = False
912
+
913
+ self.result = result
914
+
915
+ name = metadata.metadata("stackvm").get("Name", "???")
916
+ version = metadata.version("stackvm")
917
+ author = metadata.metadata("stackvm").get("Author", "???")
918
+ self.page.add(
919
+ ft.Column(
920
+ [
921
+ ft.Row(
922
+ [
923
+ ft.Column(
924
+ [ft.Text("Operand Stack", style=monospace(36))],
925
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
926
+ expand=1,
927
+ ),
928
+ ft.Column(
929
+ [ft.Text("Instructions", style=monospace(36))],
930
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
931
+ expand=1,
932
+ ),
933
+ ft.Column(
934
+ [ft.Text("Console", style=monospace(36))],
935
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
936
+ expand=1,
937
+ ),
938
+ ]
939
+ ),
940
+ ft.Row(
941
+ [
942
+ self.stack,
943
+ self.instructions,
944
+ ft.Column(
945
+ [
946
+ self.console,
947
+ ft.Text("Call Stack", style=monospace(36), text_align=ft.TextAlign.CENTER),
948
+ self.call_stack,
949
+ ],
950
+ expand=1,
951
+ horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
952
+ ),
953
+ ],
954
+ expand=True,
955
+ vertical_alignment=ft.CrossAxisAlignment.START,
956
+ ),
957
+ self.speed_control,
958
+ ft.Row(
959
+ [ft.Text(f"{name} v{version} by {author}", style=monospace(12), color=ft.Colors.GREY_500)],
960
+ alignment=ft.MainAxisAlignment.END,
961
+ ),
962
+ ],
963
+ expand=True,
964
+ horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
965
+ )
966
+ )
967
+
968
+ self.create_task(loop, page, self.executor, self.pause, self.receive, self.send)
969
+ self.create_task(self.updateloop)
970
+
971
+ async def on_window_event(event: ft.WindowEvent) -> None:
972
+ if event.type == ft.WindowEventType.CLOSE:
973
+ if self.closing:
974
+ return
975
+ self.closing = True
976
+
977
+ for task in list(self.tasks):
978
+ task.cancel()
979
+
980
+ await asyncio.gather(*map(asyncio.wrap_future, self.tasks), return_exceptions=True)
981
+
982
+ await page.window.destroy()
983
+
984
+ page.window.prevent_close = True
985
+ page.window.on_event = on_window_event
986
+
987
+ async def updateloop(self) -> None:
988
+ executor = self.executor
989
+ while True:
990
+ returned, value = await self.receive.get()
991
+
992
+ if returned:
993
+ current, value = value
994
+ else:
995
+ current = value
996
+
997
+ if executor.state.frames:
998
+ self.stack.items = executor.state.frames[-1].stack
999
+ self.stack.update()
1000
+
1001
+ self.instructions.current = current
1002
+ if executor.state.frames:
1003
+ self.instructions.next = executor.state.frames[-1].current
1004
+ self.instructions.suspended = [frame.current for frame in executor.state.frames[-2::-1]]
1005
+ self.instructions.update()
1006
+
1007
+ self.call_stack.update()
1008
+ self.console.update()
1009
+
1010
+ if returned:
1011
+ if isinstance(value, BaseException):
1012
+ self.page.show_dialog(ErrorDialog(self.page, value))
1013
+ else:
1014
+ self.page.show_dialog(ReturnDialog(self.page, value))
1015
+ self.result.set_result(value)
1016
+ return
1017
+
1018
+ await self.send.put(self.state.delay)
1019
+
1020
+ def create_task[**P, T](
1021
+ self, coroutine: Callable[P, CoroutineType[Any, Any, T]], /, *args: P.args, **kwargs: P.kwargs
1022
+ ) -> Future[T] | None:
1023
+ if self.closing:
1024
+ return None
1025
+
1026
+ future = self.page.run_task(coroutine, *args, **kwargs)
1027
+ self.tasks.add(future)
1028
+ future.add_done_callback(self.tasks.discard)
1029
+
1030
+ return future
1031
+
1032
+
1033
+ def execute(machine: StackMachine, *args: None | int | bool | str, seed: int | str | Random | None = None):
1034
+ result: Future[Any] = Future()
1035
+ ft.run(partial(App, machine, args=args, seed=seed, result=result))
1036
+ return result.result()