CreativePython 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (96) hide show
  1. {creativepython-1.0.0/src/CreativePython.egg-info → creativepython-1.1.0}/PKG-INFO +2 -1
  2. {creativepython-1.0.0 → creativepython-1.1.0}/pyproject.toml +2 -1
  3. creativepython-1.1.0/src/CreativePython/GuiHandler.py +518 -0
  4. creativepython-1.1.0/src/CreativePython/GuiRenderer.py +3222 -0
  5. {creativepython-1.0.0 → creativepython-1.1.0/src/CreativePython.egg-info}/PKG-INFO +2 -1
  6. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython.egg-info/SOURCES.txt +2 -0
  7. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython.egg-info/requires.txt +1 -0
  8. creativepython-1.1.0/src/gui.py +3887 -0
  9. creativepython-1.1.0/src/image.py +70 -0
  10. {creativepython-1.0.0 → creativepython-1.1.0}/src/midi.py +5 -5
  11. {creativepython-1.0.0 → creativepython-1.1.0}/src/music.py +20 -20
  12. {creativepython-1.0.0 → creativepython-1.1.0}/src/timer.py +32 -171
  13. creativepython-1.0.0/src/gui.py +0 -4458
  14. creativepython-1.0.0/src/image.py +0 -128
  15. {creativepython-1.0.0 → creativepython-1.1.0}/LICENSE +0 -0
  16. {creativepython-1.0.0 → creativepython-1.1.0}/LICENSE-PSF +0 -0
  17. {creativepython-1.0.0 → creativepython-1.1.0}/MANIFEST.in +0 -0
  18. {creativepython-1.0.0 → creativepython-1.1.0}/README.md +0 -0
  19. {creativepython-1.0.0 → creativepython-1.1.0}/setup.cfg +0 -0
  20. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/RealtimeAudioPlayer.py +0 -0
  21. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/__init__.py +0 -0
  22. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/Java-Comparison-Tests/advMetricRunner.pythonSurvey.py +0 -0
  23. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/Java-Comparison-Tests/compareMetrics_Java-Vs-Python.py +0 -0
  24. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/RunMetrics.py +0 -0
  25. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/Surveyor.py +0 -0
  26. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/__init__.py +0 -0
  27. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/Confidence.py +0 -0
  28. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/Contig.py +0 -0
  29. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/ExtendedNote.py +0 -0
  30. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/Histogram.py +0 -0
  31. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/Judgement.py +0 -0
  32. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/Measurement.py +0 -0
  33. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/PianoRoll.py +0 -0
  34. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/PianoRollOld.py +0 -0
  35. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/__init__.py +0 -0
  36. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/test_ExtendedNote.py +0 -0
  37. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/test_Histogram.py +0 -0
  38. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/test_Measurement.py +0 -0
  39. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/test_PianoRoll_assertions.py +0 -0
  40. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/test_PianoRoll_integration.py +0 -0
  41. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/test_PianoRoll_quantization.py +0 -0
  42. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/data/test_PianoRoll_unit.py +0 -0
  43. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/Metric.py +0 -0
  44. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/ZipfMetrics.py +0 -0
  45. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/__init__.py +0 -0
  46. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ChordDensityMetric.py +0 -0
  47. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ChordDistanceMetric.py +0 -0
  48. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ChordMetric.py +0 -0
  49. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ChordNormalizedMetric.py +0 -0
  50. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ChromaticToneMetric.py +0 -0
  51. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationMetric.py +0 -0
  52. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationQuantizedMetric.py +0 -0
  53. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ContourBasslinePitchMetric.py +0 -0
  54. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationMetric.py +0 -0
  55. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationQuantizedMetric.py +0 -0
  56. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyPitchMetric.py +0 -0
  57. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/DurationBigramMetric.py +0 -0
  58. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/DurationDistanceMetric.py +0 -0
  59. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/DurationMetric.py +0 -0
  60. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedBigramMetric.py +0 -0
  61. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedDistanceMetric.py +0 -0
  62. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedMetric.py +0 -0
  63. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/HarmonicBigramMetric.py +0 -0
  64. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/HarmonicConsonanceMetric.py +0 -0
  65. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/HarmonicIntervalMetric.py +0 -0
  66. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/MelodicBigramMetric.py +0 -0
  67. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/MelodicConsonanceMetric.py +0 -0
  68. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/MelodicIntervalMetric.py +0 -0
  69. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/PitchDistanceMetric.py +0 -0
  70. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/PitchDurationMetric.py +0 -0
  71. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/PitchDurationQuantizedMetric.py +0 -0
  72. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/PitchMetric.py +0 -0
  73. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/RestMetric.py +0 -0
  74. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/__init__.py +0 -0
  75. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/test_DurationMetric.py +0 -0
  76. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_BasicIntervalsAndBigrams.py +0 -0
  77. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ChordsAndConsonance.py +0 -0
  78. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ContoursAndChromatic.py +0 -0
  79. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_QuantizedDurationsAndDistances.py +0 -0
  80. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/test_PitchMetric.py +0 -0
  81. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/simple/test_RestMetric.py +0 -0
  82. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/metrics/test_Metric.py +0 -0
  83. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/utilities/CSVWriter.py +0 -0
  84. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/utilities/PowerLawRandom.py +0 -0
  85. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/nevmuse/utilities/__init__.py +0 -0
  86. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython/notationRenderer.py +0 -0
  87. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython.egg-info/dependency_links.txt +0 -0
  88. {creativepython-1.0.0 → creativepython-1.1.0}/src/CreativePython.egg-info/top_level.txt +0 -0
  89. {creativepython-1.0.0 → creativepython-1.1.0}/src/bin/libportaudio.2.dylib +0 -0
  90. {creativepython-1.0.0 → creativepython-1.1.0}/src/iannix.py +0 -0
  91. {creativepython-1.0.0 → creativepython-1.1.0}/src/markov.py +0 -0
  92. {creativepython-1.0.0 → creativepython-1.1.0}/src/osc.py +0 -0
  93. {creativepython-1.0.0 → creativepython-1.1.0}/src/zipf.py +0 -0
  94. {creativepython-1.0.0 → creativepython-1.1.0}/tests/testAnimate.py +0 -0
  95. {creativepython-1.0.0 → creativepython-1.1.0}/tests/testPeer.py +0 -0
  96. {creativepython-1.0.0 → creativepython-1.1.0}/tests/test_keyEvent.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CreativePython
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A Python-based software environment for developing algorithmic art projects.
5
5
  Author-email: "Dr. Bill Manaris" <manaris@cofc.edu>, Taj Ballinger <ballingertj@g.cofc.edu>, Trevor Ritchie <ritchiets@g.cofc.edu>
6
6
  License: MIT License
@@ -53,6 +53,7 @@ Description-Content-Type: text/markdown
53
53
  License-File: LICENSE
54
54
  License-File: LICENSE-PSF
55
55
  Requires-Dist: setuptools>=69.0
56
+ Requires-Dist: wheel>=0.46.0
56
57
  Requires-Dist: tinysoundfont>=0.3.6
57
58
  Requires-Dist: osc4py3>=1.0.8
58
59
  Requires-Dist: mido>=1.3.3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "CreativePython"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "A Python-based software environment for developing algorithmic art projects."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -22,6 +22,7 @@ classifiers = [
22
22
  ]
23
23
  dependencies = [
24
24
  "setuptools>=69.0",
25
+ "wheel>=0.46.0",
25
26
  "tinysoundfont>=0.3.6",
26
27
  "osc4py3>=1.0.8",
27
28
  "mido>=1.3.3",
@@ -0,0 +1,518 @@
1
+ #######################################################################################
2
+ # GuiHandler.py Version 1.0 12-Mar-2026
3
+ # Taj Ballinger, Bill Manaris
4
+ #######################################################################################
5
+ #
6
+ # GuiHandler runs in the main CreativePython process.
7
+ # It manages the GuiRenderer child process, delivers commands to Qt, and
8
+ # receives responses and events in return.
9
+ #
10
+ # This file contains no Qt code and never imports PySide6.
11
+ # All Qt activity lives in GuiRenderer.py, which runs in the child process.
12
+ #
13
+ # See GuiRenderer.py for the full protocol description.
14
+ #
15
+
16
+ import multiprocessing
17
+ import threading
18
+ import atexit
19
+
20
+
21
+ #######################################################################################
22
+ # Message Protocol Helpers
23
+ #
24
+ # Plain-dict message constructors shared by GuiHandler (parent) and GuiRenderer (child).
25
+ # Keeping them here avoids any import of Qt in the parent process — GuiRenderer imports
26
+ # these helpers from this file, not the other way around.
27
+ #######################################################################################
28
+
29
+ def _createCommand(action, target, args=None, responseId=None):
30
+ """
31
+ Creates a command message dict (CreativePython -> Qt).
32
+ """
33
+ return {
34
+ 'action': action,
35
+ 'target': target,
36
+ 'args': args if args is not None else {},
37
+ 'responseId': responseId
38
+ }
39
+
40
+ def _createResponse(responseId, values=None):
41
+ """
42
+ Creates a response message dict (Qt -> CreativePython).
43
+ """
44
+ return {
45
+ 'responseId': responseId,
46
+ 'values': values if values is not None else []
47
+ }
48
+
49
+ def _createEvent(eventType, target, args=None):
50
+ """
51
+ Creates an event message dict (Qt -> CreativePython).
52
+ """
53
+ return {
54
+ 'type': eventType,
55
+ 'target': target,
56
+ 'args': args if args is not None else {}
57
+ }
58
+
59
+
60
+ #######################################################################################
61
+ # Child process entry point
62
+ #######################################################################################
63
+
64
+ def _launchRenderer(childConn):
65
+ """
66
+ Entry point for the GuiRenderer child process.
67
+ Defined here so GuiHandler can reference it without importing from GuiRenderer
68
+ globally (which would also import Qt/PySide6 in the parent process).
69
+
70
+ At runtime this function only executes in the child process.
71
+ """
72
+ import sys, os
73
+
74
+ # Suppress macOS system noise (stale XPC connections inherited via fork)
75
+ # that would confuse students. Redirect at the OS level (fd 2) so that
76
+ # C-level libraries (Cocoa, XPC) are also silenced.
77
+ _devnull = os.open(os.devnull, os.O_WRONLY)
78
+ os.dup2(_devnull, 2)
79
+ os.close(_devnull)
80
+ sys.stderr = open(os.devnull, 'w')
81
+
82
+ from CreativePython.GuiRenderer import GuiRenderer
83
+ renderer = GuiRenderer(childConn)
84
+ renderer.run()
85
+
86
+
87
+ #######################################################################################
88
+ # GuiHandler
89
+ #######################################################################################
90
+
91
+ class GuiHandler:
92
+ """
93
+ Runs in the main CreativePython process.
94
+ Manages the GuiRenderer child process, sends commands, and receives
95
+ responses and events. Instantiated once as the module-level singleton
96
+ _GUI_HANDLER when gui.py is imported; never exposed directly to the user.
97
+ """
98
+
99
+ def __init__(self):
100
+ """
101
+ Spawns the GuiRenderer child process and establishes the IPC Pipe.
102
+ Starts a background listener thread that routes incoming messages to
103
+ waiting sendQuery() callers or registered event callbacks.
104
+ Registers an atexit handler so the child is always cleaned up when
105
+ the parent process exits — no cleanup code required in gui.py.
106
+ """
107
+ parentConn, childConn = multiprocessing.Pipe(duplex=True)
108
+ self.connection = parentConn
109
+
110
+ # Choose the spawn context based on the runtime environment:
111
+ #
112
+ # fork — fastest; child inherits parent memory without re-importing
113
+ # anything. Safe when the parent has no Cocoa/AppKit state.
114
+ #
115
+ # forkserver — the forkserver helper is started once via 'spawn' (clean,
116
+ # no inherited GUI state); each child is then forked from
117
+ # that clean server. Required under PENCIL: PENCIL's
118
+ # user-subprocess imports tkinter (Cocoa/AppKit), and forking
119
+ # directly from that process prevents Qt from connecting to
120
+ # the macOS window server.
121
+ #
122
+ # spawn — every child is a fresh Python process; re-imports __main__,
123
+ # which would re-run the user's script. Used on Windows where
124
+ # fork is unavailable.
125
+ #
126
+ # Detection: if tkinter has been imported into the current process, Cocoa is
127
+ # live and we must avoid fork. Otherwise, fork is safe and avoids the
128
+ # __main__ re-import problem that spawn/forkserver have.
129
+ import sys
130
+ if sys.platform == 'win32':
131
+ ctx = multiprocessing.get_context('spawn')
132
+ elif getattr(sys, 'frozen', False):
133
+ # Running inside a PyInstaller frozen executable. forkserver spawns
134
+ # its helper by calling sys.executable as a Python interpreter, which
135
+ # doesn't work when sys.executable is the frozen app binary. spawn
136
+ # is safe here because freeze_support() in the entry point intercepts
137
+ # the --multiprocessing-fork flag before any app code runs.
138
+ ctx = multiprocessing.get_context('spawn')
139
+ elif 'tkinter' in sys.modules:
140
+ ctx = multiprocessing.get_context('forkserver')
141
+ else:
142
+ ctx = multiprocessing.get_context('fork')
143
+ # With 'spawn' on POSIX (fork+exec), the forked helper calls
144
+ # get_preparation_data() which reads sys.modules['__main__'].__file__.
145
+ # When a user script is running in PENCIL's subprocess, __main__.__file__
146
+ # is set to that script's path (IDLE does this before exec'ing the script).
147
+ # That path ends up in the bootstrap pipe; the GuiRenderer child's prepare()
148
+ # then re-imports the user's script before _launchRenderer ever runs.
149
+ # If the script raises at module level (no __main__ guard, missing imports,
150
+ # etc.) GuiRenderer crashes silently and the pipe breaks.
151
+ # Fix: clear __main__.__file__ and __spec__ for the duration of the fork so
152
+ # get_preparation_data() omits main_path and prepare() skips the re-import.
153
+ _main_mod = sys.modules.get('__main__')
154
+ _saved_file = getattr(_main_mod, '__file__', _UNSET := object())
155
+ _saved_spec = getattr(_main_mod, '__spec__', _UNSET)
156
+ if _main_mod is not None:
157
+ _main_mod.__file__ = None
158
+ _main_mod.__spec__ = None
159
+ try:
160
+ self.childProcess = ctx.Process(
161
+ target = _launchRenderer,
162
+ args = (childConn,),
163
+ daemon = True # child is killed automatically if parent dies unexpectedly
164
+ )
165
+ self.childProcess.start()
166
+ finally:
167
+ if _main_mod is not None:
168
+ if _saved_file is not _UNSET:
169
+ _main_mod.__file__ = _saved_file
170
+ if _saved_spec is not _UNSET:
171
+ _main_mod.__spec__ = _saved_spec
172
+ childConn.close() # parent no longer needs the child end of the Pipe
173
+
174
+ # responseId counter — each sendQuery() call gets a unique ID so the
175
+ # listener thread can route the response back to the correct caller
176
+ self._responseIdCount = 0
177
+ self._responseIdLock = threading.Lock()
178
+
179
+ # pending responses — maps responseId -> {'event': threading.Event, 'values': list}
180
+ # sendQuery() registers a slot here before sending, then blocks on the Event.
181
+ # The listener thread writes values into the slot and signals the Event.
182
+ self._pendingResponses = {}
183
+ self._pendingLock = threading.Lock()
184
+
185
+ # event registry — maps (objectId, eventType) -> callback function
186
+ # Populated by registerEvent(); consulted by the listener thread on each event.
187
+ self._eventRegistry = {}
188
+
189
+ # send lock — Pipe.send() is not thread-safe; all outgoing messages
190
+ # must acquire this lock before writing to the pipe
191
+ self._sendLock = threading.Lock()
192
+
193
+ # single listener thread handles both responses and events, since both
194
+ # arrive on the same connection and reading from it on two threads would race
195
+ self._listenerThread = threading.Thread(
196
+ target = self._listenForMessages,
197
+ daemon = True # thread exits automatically when the main process exits
198
+ )
199
+ self._listenerThread.start()
200
+
201
+ atexit.register(self._shutdown)
202
+
203
+ # ── Response ID ───────────────────────────────────────────────────────────
204
+
205
+ def _nextResponseId(self):
206
+ """
207
+ Returns the next unique responseId. Thread-safe.
208
+ """
209
+ with self._responseIdLock:
210
+ self._responseIdCount += 1
211
+ return self._responseIdCount
212
+
213
+ # ── Listener thread ───────────────────────────────────────────────────────
214
+
215
+ def _listenForMessages(self):
216
+ """
217
+ Background thread. Reads all incoming messages from GuiRenderer and
218
+ routes each one based on its type:
219
+ - 'responseId' key present → response for a waiting sendQuery() call
220
+ - 'type' key present → event for a registered callback
221
+ Exits when the pipe is closed (EOFError).
222
+ """
223
+ while True:
224
+ try:
225
+ message = self.connection.recv()
226
+ if 'responseId' in message:
227
+ self._handleResponse(message)
228
+ elif 'type' in message:
229
+ self._dispatchEvent(message)
230
+ except EOFError:
231
+ break # pipe closed; thread exits cleanly
232
+
233
+ def _handleResponse(self, responseDict):
234
+ """
235
+ Routes an incoming response to the sendQuery() call that is waiting for it.
236
+ Called from the listener thread.
237
+ """
238
+ responseId = responseDict.get('responseId')
239
+ values = responseDict.get('values', [])
240
+
241
+ with self._pendingLock:
242
+ pendingSlot = self._pendingResponses.get(responseId)
243
+
244
+ if pendingSlot is not None:
245
+ pendingSlot['values'] = values # store result before signalling
246
+ pendingSlot['event'].set() # unblock the waiting sendQuery() call
247
+
248
+ # ── Sending ───────────────────────────────────────────────────────────────
249
+
250
+ def sendCommand(self, action, target, args=None):
251
+ """
252
+ Sends a fire-and-forget command to GuiRenderer (no response expected).
253
+ Thread-safe.
254
+ """
255
+ command = _createCommand(action, target, args)
256
+ with self._sendLock:
257
+ self.connection.send(command)
258
+
259
+ def sendQuery(self, action, target, args=None):
260
+ """
261
+ Sends a command to GuiRenderer and blocks until a response is received.
262
+ Returns the response's values list. Thread-safe; multiple callers may
263
+ block concurrently — each is matched to its response by responseId.
264
+ """
265
+ responseId = self._nextResponseId()
266
+ pendingSlot = {'event': threading.Event(), 'values': None}
267
+
268
+ # register the slot before sending so the listener never misses the response
269
+ with self._pendingLock:
270
+ self._pendingResponses[responseId] = pendingSlot
271
+
272
+ command = _createCommand(action, target, args, responseId=responseId)
273
+ with self._sendLock:
274
+ self.connection.send(command)
275
+
276
+ pendingSlot['event'].wait() # block until the listener signals a response
277
+
278
+ with self._pendingLock:
279
+ del self._pendingResponses[responseId] # clean up the slot
280
+
281
+ return pendingSlot['values']
282
+
283
+ # ── Developer utilities ───────────────────────────────────────────────────
284
+
285
+ def getBatchSize(self):
286
+ """
287
+ Returns the current _BATCH_SIZE used by GuiRenderer's pipe-polling loop.
288
+ Not part of the public API — intended for developer tuning only.
289
+ """
290
+ result = self.sendQuery('getBatchSize', None)
291
+ return result[0]
292
+
293
+ def setBatchSize(self, batchSize):
294
+ """
295
+ Sets GuiRenderer's _BATCH_SIZE on the fly.
296
+ Higher values process more commands between Qt repaints (better throughput,
297
+ less responsive feel); lower values yield more frequent repaints.
298
+ Not part of the public API — intended for developer tuning only.
299
+ """
300
+ self.sendCommand('setBatchSize', None, {'batchSize': batchSize})
301
+
302
+ # ── Events ────────────────────────────────────────────────────────────────
303
+
304
+ def registerEvent(self, objectId, eventType, callback):
305
+ """
306
+ Registers a callback for a specific event type on a specific object.
307
+ Stores the callback locally and notifies GuiRenderer to watch for the
308
+ corresponding Qt event on that object.
309
+ When GuiRenderer sends a matching event, the callback is invoked with
310
+ the event's args dict unpacked as keyword arguments.
311
+ """
312
+ self._eventRegistry[(objectId, eventType)] = callback
313
+
314
+ registrationArgs = {'objectId': objectId, 'eventType': eventType}
315
+ self.sendCommand('registerEvent', None, registrationArgs)
316
+
317
+ def _dispatchEvent(self, eventDict):
318
+ """
319
+ Looks up and calls the callback registered for the incoming event.
320
+ Called from the listener thread; the callback runs on that thread.
321
+ """
322
+ eventType = eventDict.get('type')
323
+ objectId = eventDict.get('target')
324
+ args = eventDict.get('args', {})
325
+
326
+ callback = self._eventRegistry.get((objectId, eventType))
327
+ if callback is not None and callable(callback):
328
+ if isinstance(args, list):
329
+ callback(*args)
330
+ else:
331
+ callback(**args)
332
+
333
+ # ── Shutdown ──────────────────────────────────────────────────────────────
334
+
335
+ def _shutdown(self):
336
+ """
337
+ Kills the child process immediately and closes the pipe.
338
+ Called automatically via atexit when the parent process exits.
339
+ The child is killed with SIGKILL so it does not drain its event queue —
340
+ exit() in the parent should return promptly.
341
+ """
342
+ if self.childProcess.is_alive():
343
+ # SIGTERM is overridden in the child to call os._exit(0) immediately,
344
+ # bypassing Qt's graceful shutdown (which drains the event queue).
345
+ # Python delivers SIGTERM between bytecodes, so it fires within one
346
+ # iteration of the child's _pollPipe loop — far faster than SIGKILL,
347
+ # which macOS may defer while a Metal/GPU operation is in flight.
348
+ self.childProcess.terminate()
349
+ self.childProcess.join(timeout=0.5) # should die in < 1 ms
350
+ if self.childProcess.is_alive():
351
+ self.childProcess.kill() # backup: SIGKILL
352
+ self.childProcess.join(timeout=2.0)
353
+ self.connection.close()
354
+
355
+
356
+ #######################################################################################
357
+ # Handler factory
358
+ #######################################################################################
359
+
360
+ class _NullHandler:
361
+ """
362
+ Stub returned by _createHandler() when called from a spawned child process.
363
+
364
+ On Windows (and forkserver on macOS), multiprocessing re-imports __main__
365
+ to reconstruct the child's environment. Any top-level Display or shape
366
+ creation in the user's script would run again during that re-import.
367
+ Returning this stub makes all those calls silently do nothing instead of
368
+ crashing with a RuntimeError or starting another child process.
369
+ """
370
+ def sendCommand(self, action, target, args=None): pass
371
+ def sendQuery(self, action, target, args=None): return [0, 0, 0, 0]
372
+ def registerEvent(self, objectId, eventType, callback): pass
373
+ def getBatchSize(self): return 0
374
+ def setBatchSize(self, batchSize): pass
375
+
376
+
377
+ def _createHandler():
378
+ """
379
+ Returns a GuiHandler if called from the main process, or a _NullHandler
380
+ if called from a spawned/forked child process (e.g. during __main__ re-import).
381
+ Also calls freeze_support() and waits briefly for the child process to start.
382
+ """
383
+ if multiprocessing.parent_process() is not None:
384
+ return _NullHandler()
385
+ multiprocessing.freeze_support()
386
+ import time
387
+ handler = GuiHandler()
388
+ time.sleep(0.2) # allow child process to start
389
+ return handler
390
+
391
+
392
+ #######################################################################################
393
+ # Phase 4 Test
394
+ #######################################################################################
395
+
396
+ if __name__ == '__main__':
397
+ # On macOS/Windows, multiprocessing uses 'spawn' by default.
398
+ # This guard prevents the child process from re-running the test on import.
399
+ multiprocessing.freeze_support()
400
+
401
+ import time
402
+
403
+ PASS = '\033[92m✓\033[0m'
404
+ FAIL = '\033[91m✗\033[0m'
405
+
406
+ def check(label, condition):
407
+ print(f' {PASS if condition else FAIL} {label}')
408
+ if not condition:
409
+ raise AssertionError(f'FAILED: {label}')
410
+
411
+ print()
412
+ print('GuiHandler / GuiRenderer — Phase 4 Test')
413
+ print('=' * 40)
414
+ print(' (A small window will appear briefly during this test.)')
415
+
416
+ handler = GuiHandler()
417
+ time.sleep(0.2) # allow Qt event loop to start
418
+
419
+ DISPLAY_ID = 1 # objectId used for the test Display
420
+
421
+ # ── Create Display ────────────────────────────────────────────────────────
422
+ print('\n[1] Create Display')
423
+
424
+ createArgs = {
425
+ 'type': 'Display',
426
+ 'title': 'Phase 4 Test',
427
+ 'width': 300,
428
+ 'height': 200,
429
+ 'x': 100,
430
+ 'y': 100,
431
+ 'color': [255, 255, 255, 255] # white
432
+ }
433
+ handler.sendCommand('create', DISPLAY_ID, createArgs)
434
+ time.sleep(0.2) # allow Qt to create and show the window
435
+
436
+ pingResult = handler.sendQuery('ping', None)
437
+ check('Qt event loop alive after create', pingResult == ['pong'])
438
+
439
+ # ── Query initial state ───────────────────────────────────────────────────
440
+ print('\n[2] Query initial state')
441
+
442
+ titleResult = handler.sendQuery('getTitle', DISPLAY_ID)
443
+ check('getTitle returns "Phase 4 Test"', titleResult == ['Phase 4 Test'])
444
+
445
+ sizeResult = handler.sendQuery('getSize', DISPLAY_ID)
446
+ check('getSize returns [300, 200]', sizeResult == [300, 200])
447
+
448
+ widthResult = handler.sendQuery('getWidth', DISPLAY_ID)
449
+ check('getWidth returns 300', widthResult == [300])
450
+
451
+ heightResult = handler.sendQuery('getHeight', DISPLAY_ID)
452
+ check('getHeight returns 200', heightResult == [200])
453
+
454
+ colorResult = handler.sendQuery('getColor', DISPLAY_ID)
455
+ check('getColor returns [255, 255, 255, 255]', colorResult == [255, 255, 255, 255])
456
+
457
+ posResult = handler.sendQuery('getPosition', DISPLAY_ID)
458
+ check('getPosition returns two integers',
459
+ len(posResult) == 2 and all(isinstance(v, int) for v in posResult))
460
+
461
+ # ── Setters round-trip ────────────────────────────────────────────────────
462
+ print('\n[3] Setters round-trip')
463
+
464
+ handler.sendCommand('setTitle', DISPLAY_ID, {'title': 'Updated Title'})
465
+ titleResult2 = handler.sendQuery('getTitle', DISPLAY_ID)
466
+ check('setTitle → getTitle round-trips', titleResult2 == ['Updated Title'])
467
+
468
+ handler.sendCommand('setColor', DISPLAY_ID, {'color': [0, 0, 255, 255]})
469
+ colorResult2 = handler.sendQuery('getColor', DISPLAY_ID)
470
+ check('setColor → getColor round-trips', colorResult2 == [0, 0, 255, 255])
471
+
472
+ handler.sendCommand('setSize', DISPLAY_ID, {'width': 400, 'height': 300})
473
+ sizeResult2 = handler.sendQuery('getSize', DISPLAY_ID)
474
+ check('setSize → getSize round-trips', sizeResult2 == [400, 300])
475
+
476
+ handler.sendCommand('setPosition', DISPLAY_ID, {'x': 150, 'y': 150})
477
+ posResult2 = handler.sendQuery('getPosition', DISPLAY_ID)
478
+ check('setPosition returns two integers',
479
+ len(posResult2) == 2 and all(isinstance(v, int) for v in posResult2))
480
+
481
+ # ── Visibility ────────────────────────────────────────────────────────────
482
+ print('\n[4] Visibility')
483
+
484
+ handler.sendCommand('hide', DISPLAY_ID)
485
+ time.sleep(0.1)
486
+ check('Qt alive after hide', handler.sendQuery('ping', None) == ['pong'])
487
+
488
+ handler.sendCommand('show', DISPLAY_ID)
489
+ time.sleep(0.1)
490
+ check('Qt alive after show', handler.sendQuery('ping', None) == ['pong'])
491
+
492
+ time.sleep(5) # hang for a few seconds to show Display
493
+
494
+ # ── displayClose event ────────────────────────────────────────────────────
495
+ print('\n[5] displayClose event')
496
+
497
+ closeReceived = []
498
+
499
+ def onDisplayClose():
500
+ closeReceived.append(True)
501
+
502
+ handler.registerEvent(DISPLAY_ID, 'displayClose', onDisplayClose)
503
+ time.sleep(0.1)
504
+
505
+ handler.sendCommand('close', DISPLAY_ID)
506
+ time.sleep(0.2)
507
+
508
+ check('displayClose event received', len(closeReceived) == 1)
509
+
510
+ # ── Shutdown ──────────────────────────────────────────────────────────────
511
+ print('\n[6] Shutdown')
512
+
513
+ handler._shutdown()
514
+ check('child process has exited', not handler.childProcess.is_alive())
515
+
516
+ print()
517
+ print('All Phase 4 tests passed.')
518
+ print()