CreativePython 1.1.2__tar.gz → 1.1.3__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.
- {creativepython-1.1.2/src/CreativePython.egg-info → creativepython-1.1.3}/PKG-INFO +1 -1
- {creativepython-1.1.2 → creativepython-1.1.3}/pyproject.toml +1 -1
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/GuiHandler.py +236 -66
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/GuiRenderer.py +371 -324
- {creativepython-1.1.2 → creativepython-1.1.3/src/CreativePython.egg-info}/PKG-INFO +1 -1
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/SOURCES.txt +1 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/gui.py +3 -3
- creativepython-1.1.3/tests/testHitTesting.py +28 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/LICENSE +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/LICENSE-PSF +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/MANIFEST.in +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/README.md +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/setup.cfg +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/RealtimeAudioPlayer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/Java-Comparison-Tests/advMetricRunner.pythonSurvey.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/Java-Comparison-Tests/compareMetrics_Java-Vs-Python.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/RunMetrics.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/Surveyor.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Confidence.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Contig.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/ExtendedNote.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Histogram.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Judgement.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Measurement.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/PianoRoll.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/PianoRollOld.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_ExtendedNote.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_Histogram.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_Measurement.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_assertions.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_integration.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_quantization.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_unit.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/Metric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/ZipfMetrics.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordDensityMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordNormalizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChromaticToneMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourBasslinePitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyPitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/HarmonicBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/HarmonicConsonanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/HarmonicIntervalMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/MelodicBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/MelodicConsonanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/MelodicIntervalMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchDurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchDurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/RestMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_DurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_BasicIntervalsAndBigrams.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ChordsAndConsonance.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ContoursAndChromatic.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_QuantizedDurationsAndDistances.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_PitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_RestMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/test_Metric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/utilities/CSVWriter.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/utilities/PowerLawRandom.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/utilities/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/notationRenderer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/dependency_links.txt +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/requires.txt +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/top_level.txt +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/bin/libportaudio.2.dylib +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/iannix.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/image.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/markov.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/midi.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/music.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/osc.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/timer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/src/zipf.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/tests/testAnimate.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/tests/testPeer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.3}/tests/test_keyEvent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: CreativePython
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "CreativePython"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.3"
|
|
8
8
|
description = "A Python-based software environment for developing algorithmic art projects."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import multiprocessing
|
|
17
17
|
import threading
|
|
18
|
+
import queue
|
|
18
19
|
import atexit
|
|
19
20
|
|
|
21
|
+
# Sender thread tick rate. High enough that the final partial batch after a
|
|
22
|
+
# burst (e.g. a tight for loop) reaches Qt within a few milliseconds.
|
|
23
|
+
_FLUSH_RATE = 200
|
|
24
|
+
_MAX_BUFFER_SIZE = 512 # flush inline when buffer reaches this size
|
|
20
25
|
|
|
21
26
|
#######################################################################################
|
|
22
27
|
# Message Protocol Helpers
|
|
@@ -61,27 +66,28 @@ def _createEvent(eventType, target, args=None):
|
|
|
61
66
|
# Child process entry point
|
|
62
67
|
#######################################################################################
|
|
63
68
|
|
|
64
|
-
def _launchRenderer(childConn,
|
|
69
|
+
def _launchRenderer(childConn, adminChildConn, adminParentConn):
|
|
65
70
|
"""
|
|
66
71
|
Entry point for the GuiRenderer child process.
|
|
67
72
|
Defined here so GuiHandler can reference it without importing from GuiRenderer
|
|
68
73
|
globally (which would also import Qt/PySide6 in the parent process).
|
|
69
74
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
the parent closes
|
|
75
|
+
adminChildConn is the child end of the duplex admin pipe; the child watches it
|
|
76
|
+
with QSocketNotifier. Admin commands (setRate, getRate) arrive here, bypassing
|
|
77
|
+
the command buffer. When the parent closes its end (adminParentConn), the child
|
|
78
|
+
receives EOF and initiates shutdown.
|
|
79
|
+
|
|
80
|
+
adminParentConn is the parent's end; the child closes it immediately so that
|
|
81
|
+
only the parent holds it — ensuring the child sees EOF when the parent closes.
|
|
76
82
|
|
|
77
83
|
At runtime this function only executes in the child process.
|
|
78
84
|
"""
|
|
79
85
|
import sys, os
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
adminParentConn.close() # child must not hold the parent end open
|
|
82
88
|
|
|
83
|
-
# Suppress macOS system noise
|
|
84
|
-
#
|
|
89
|
+
# Suppress macOS system noise that would confuse students.
|
|
90
|
+
# Redirect at the OS level (fd 2) so that
|
|
85
91
|
# C-level libraries (Cocoa, XPC) are also silenced.
|
|
86
92
|
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
87
93
|
os.dup2(_devnull, 2)
|
|
@@ -89,7 +95,7 @@ def _launchRenderer(childConn, shutdownReadConn, shutdownWriteConn):
|
|
|
89
95
|
sys.stderr = open(os.devnull, 'w')
|
|
90
96
|
|
|
91
97
|
from CreativePython.GuiRenderer import GuiRenderer
|
|
92
|
-
renderer = GuiRenderer(childConn,
|
|
98
|
+
renderer = GuiRenderer(childConn, adminChildConn)
|
|
93
99
|
renderer.run()
|
|
94
100
|
|
|
95
101
|
|
|
@@ -132,31 +138,32 @@ class GuiHandler:
|
|
|
132
138
|
# which would re-run the user's script. Used on Windows where
|
|
133
139
|
# fork is unavailable.
|
|
134
140
|
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
141
|
+
# On macOS, fork() is unsafe whenever the parent has any CoreFoundation /
|
|
142
|
+
# Cocoa state — not just when tkinter is explicitly imported. The Python
|
|
143
|
+
# runtime on macOS initialises parts of CoreFoundation at startup, so a
|
|
144
|
+
# forked child that then creates a QApplication reliably triggers the
|
|
145
|
+
# "You MUST exec()" crash. forkserver avoids this by forking from a clean
|
|
146
|
+
# helper process that has never touched Cocoa.
|
|
147
|
+
# On Windows, spawn is required (no fork).
|
|
148
|
+
# Inside a PyInstaller frozen binary, forkserver can't work (sys.executable
|
|
149
|
+
# is the frozen app, not a Python interpreter), so spawn is used instead.
|
|
138
150
|
import sys
|
|
139
151
|
if sys.platform == 'win32':
|
|
140
152
|
ctx = multiprocessing.get_context('spawn')
|
|
141
153
|
elif getattr(sys, 'frozen', False):
|
|
142
|
-
# Running inside a PyInstaller frozen executable. forkserver spawns
|
|
143
|
-
# its helper by calling sys.executable as a Python interpreter, which
|
|
144
|
-
# doesn't work when sys.executable is the frozen app binary. spawn
|
|
145
|
-
# is safe here because freeze_support() in the entry point intercepts
|
|
146
|
-
# the --multiprocessing-fork flag before any app code runs.
|
|
147
154
|
ctx = multiprocessing.get_context('spawn')
|
|
148
|
-
elif '
|
|
155
|
+
elif sys.platform == 'darwin':
|
|
149
156
|
ctx = multiprocessing.get_context('forkserver')
|
|
150
157
|
else:
|
|
151
158
|
ctx = multiprocessing.get_context('fork')
|
|
152
159
|
|
|
153
|
-
# Dedicated
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
|
|
159
|
-
self.
|
|
160
|
+
# Dedicated duplex admin pipe. The parent holds adminParentConn; the child
|
|
161
|
+
# holds adminChildConn. Admin commands (setRate, getRate) travel here,
|
|
162
|
+
# bypassing the command buffer entirely. Closing adminParentConn sends EOF
|
|
163
|
+
# to the child, which Qt detects via QSocketNotifier and treats as shutdown —
|
|
164
|
+
# independent of the command queue and independent of whether atexit runs.
|
|
165
|
+
adminChildConn, adminParentConn = multiprocessing.Pipe(duplex=True)
|
|
166
|
+
self._adminConn = adminParentConn
|
|
160
167
|
|
|
161
168
|
# With 'spawn' on POSIX (fork+exec), the forked helper calls
|
|
162
169
|
# get_preparation_data() which reads sys.modules['__main__'].__file__.
|
|
@@ -177,7 +184,7 @@ class GuiHandler:
|
|
|
177
184
|
try:
|
|
178
185
|
self.childProcess = ctx.Process(
|
|
179
186
|
target = _launchRenderer,
|
|
180
|
-
args = (childConn,
|
|
187
|
+
args = (childConn, adminChildConn, adminParentConn),
|
|
181
188
|
daemon = True # child is killed automatically if parent dies unexpectedly
|
|
182
189
|
)
|
|
183
190
|
self.childProcess.start()
|
|
@@ -187,8 +194,8 @@ class GuiHandler:
|
|
|
187
194
|
_main_mod.__file__ = _saved_file
|
|
188
195
|
if _saved_spec is not _UNSET:
|
|
189
196
|
_main_mod.__spec__ = _saved_spec
|
|
190
|
-
childConn.close()
|
|
191
|
-
|
|
197
|
+
childConn.close() # parent no longer needs the child end of the command pipe
|
|
198
|
+
adminChildConn.close() # parent no longer needs the child end of the admin pipe
|
|
192
199
|
|
|
193
200
|
# responseId counter — each sendQuery() call gets a unique ID so the
|
|
194
201
|
# listener thread can route the response back to the correct caller
|
|
@@ -209,14 +216,51 @@ class GuiHandler:
|
|
|
209
216
|
# must acquire this lock before writing to the pipe
|
|
210
217
|
self._sendLock = threading.Lock()
|
|
211
218
|
|
|
219
|
+
# command buffer — fire-and-forget commands are queued here and sent as
|
|
220
|
+
# a single batch by the sender thread (or flushed early on a query)
|
|
221
|
+
self._commandBuffer = []
|
|
222
|
+
self._bufferLock = threading.Lock()
|
|
223
|
+
|
|
224
|
+
# flush rate - how often to flush the buffer
|
|
225
|
+
self._flushRate = _FLUSH_RATE
|
|
226
|
+
self._maxBufferSize = _MAX_BUFFER_SIZE
|
|
227
|
+
|
|
228
|
+
# callback active lock — held by the dispatch thread while an event callback
|
|
229
|
+
# is executing. The sender thread checks this non-blocking; if it can't
|
|
230
|
+
# acquire, it skips that flush tick so mid-callback commands don't travel as
|
|
231
|
+
# a partial batch. After the callback, the dispatch thread does one explicit
|
|
232
|
+
# flush to send everything the callback accumulated as a single pipe message.
|
|
233
|
+
self._callbackActive = threading.Lock()
|
|
234
|
+
|
|
235
|
+
# sender thread — wakes every 1/FLUSH_RATE seconds and flushes the buffer
|
|
236
|
+
self._senderStopEvent = threading.Event()
|
|
237
|
+
self._senderThread = threading.Thread(
|
|
238
|
+
target = self._runSenderThread,
|
|
239
|
+
daemon = True
|
|
240
|
+
)
|
|
241
|
+
self._senderThread.start()
|
|
242
|
+
|
|
243
|
+
# event queue — listener thread enqueues incoming events here; dispatch
|
|
244
|
+
# thread drains it and runs callbacks. Decoupling the two threads means
|
|
245
|
+
# callbacks can call sendQuery() without deadlocking the listener.
|
|
246
|
+
self._eventQueue = queue.SimpleQueue()
|
|
247
|
+
|
|
212
248
|
# single listener thread handles both responses and events, since both
|
|
213
249
|
# arrive on the same connection and reading from it on two threads would race
|
|
214
250
|
self._listenerThread = threading.Thread(
|
|
215
251
|
target = self._listenForMessages,
|
|
216
|
-
daemon = True
|
|
252
|
+
daemon = True
|
|
217
253
|
)
|
|
218
254
|
self._listenerThread.start()
|
|
219
255
|
|
|
256
|
+
# dispatch thread runs event callbacks sequentially off the event queue,
|
|
257
|
+
# keeping the listener thread free to receive query responses at all times
|
|
258
|
+
self._dispatchThread = threading.Thread(
|
|
259
|
+
target = self._runDispatchThread,
|
|
260
|
+
daemon = True
|
|
261
|
+
)
|
|
262
|
+
self._dispatchThread.start()
|
|
263
|
+
|
|
220
264
|
atexit.register(self._shutdown)
|
|
221
265
|
|
|
222
266
|
# ── Response ID ───────────────────────────────────────────────────────────
|
|
@@ -234,9 +278,10 @@ class GuiHandler:
|
|
|
234
278
|
def _listenForMessages(self):
|
|
235
279
|
"""
|
|
236
280
|
Background thread. Reads incoming messages from GuiRenderer and routes
|
|
237
|
-
each one: responses unblock waiting sendQuery() callers; events
|
|
238
|
-
|
|
239
|
-
|
|
281
|
+
each one: responses unblock waiting sendQuery() callers; events are
|
|
282
|
+
pushed onto _eventQueue for the dispatch thread. Exits when the child
|
|
283
|
+
closes the pipe (EOFError), releases any waiting sendQuery() callers,
|
|
284
|
+
and sends a None sentinel to stop the dispatch thread.
|
|
240
285
|
"""
|
|
241
286
|
while True:
|
|
242
287
|
try:
|
|
@@ -244,13 +289,14 @@ class GuiHandler:
|
|
|
244
289
|
if 'responseId' in message:
|
|
245
290
|
self._handleResponse(message)
|
|
246
291
|
elif 'type' in message:
|
|
247
|
-
self.
|
|
292
|
+
self._eventQueue.put(message)
|
|
248
293
|
except EOFError:
|
|
249
294
|
break
|
|
250
295
|
with self._pendingLock:
|
|
251
296
|
for slot in self._pendingResponses.values():
|
|
252
297
|
slot['values'] = ['shutdown']
|
|
253
298
|
slot['event'].set()
|
|
299
|
+
self._eventQueue.put(None) # sentinel: tell dispatch thread to stop
|
|
254
300
|
|
|
255
301
|
def _handleResponse(self, responseDict):
|
|
256
302
|
"""
|
|
@@ -267,22 +313,91 @@ class GuiHandler:
|
|
|
267
313
|
pendingSlot['values'] = values # store result before signalling
|
|
268
314
|
pendingSlot['event'].set() # unblock the waiting sendQuery() call
|
|
269
315
|
|
|
316
|
+
# ── Sender thread ─────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
def _runSenderThread(self):
|
|
319
|
+
"""
|
|
320
|
+
Background thread. Wakes every 1 / _flushRate seconds and flushes the
|
|
321
|
+
command buffer. Skips a tick if a callback is currently executing
|
|
322
|
+
(_callbackActive is held) so the callback's commands don't get split
|
|
323
|
+
across multiple pipe messages. Exits when _senderStopEvent is set.
|
|
324
|
+
"""
|
|
325
|
+
while not self._senderStopEvent.wait(timeout=1.0 / self._flushRate):
|
|
326
|
+
if self._callbackActive.acquire(blocking=False):
|
|
327
|
+
self._callbackActive.release()
|
|
328
|
+
self._flushBuffer()
|
|
329
|
+
# else: callback in progress — skip this tick
|
|
330
|
+
self._flushBuffer() # final drain on shutdown
|
|
331
|
+
|
|
332
|
+
def _flushBuffer(self):
|
|
333
|
+
"""
|
|
334
|
+
Sends the current command buffer as a single batch message, then resets
|
|
335
|
+
the buffer. Thread-safe; concurrent callers are safe — only one will
|
|
336
|
+
get a non-empty batch, the others are no-ops.
|
|
337
|
+
"""
|
|
338
|
+
with self._bufferLock:
|
|
339
|
+
batch = self._commandBuffer
|
|
340
|
+
if not batch:
|
|
341
|
+
return
|
|
342
|
+
self._commandBuffer = []
|
|
343
|
+
with self._sendLock:
|
|
344
|
+
try:
|
|
345
|
+
self.connection.send(batch)
|
|
346
|
+
except (BrokenPipeError, OSError):
|
|
347
|
+
self._senderStopEvent.set() # pipe is gone; stop the sender thread
|
|
348
|
+
|
|
349
|
+
def getFlushRate(self):
|
|
350
|
+
"""
|
|
351
|
+
Returns the current tick rate for flushing the command buffer.
|
|
352
|
+
"""
|
|
353
|
+
return self._flushRate
|
|
354
|
+
|
|
355
|
+
def setFlushRate(self, flushRate):
|
|
356
|
+
"""
|
|
357
|
+
Sets the current tick rate for flushing the command buffer.
|
|
358
|
+
"""
|
|
359
|
+
self._flushRate = flushRate
|
|
360
|
+
|
|
361
|
+
def getRate(self):
|
|
362
|
+
"""
|
|
363
|
+
Returns the current render rate (timer ticks per second) of the Qt renderer.
|
|
364
|
+
Sends directly on the admin pipe, bypassing the command buffer.
|
|
365
|
+
"""
|
|
366
|
+
self._adminConn.send({'action': 'getRate'})
|
|
367
|
+
return self._adminConn.recv()
|
|
368
|
+
|
|
369
|
+
def setRate(self, rate):
|
|
370
|
+
"""
|
|
371
|
+
Sets the render rate (timer ticks per second) of the Qt renderer.
|
|
372
|
+
Sends directly on the admin pipe, bypassing the command buffer.
|
|
373
|
+
"""
|
|
374
|
+
self._adminConn.send({'action': 'setRate', 'rate': rate})
|
|
375
|
+
|
|
270
376
|
# ── Sending ───────────────────────────────────────────────────────────────
|
|
271
377
|
|
|
272
378
|
def sendCommand(self, action, target, args=None):
|
|
273
379
|
"""
|
|
274
|
-
|
|
275
|
-
Thread-safe.
|
|
380
|
+
Queues a fire-and-forget command in the command buffer. The sender thread
|
|
381
|
+
flushes on each tick. Thread-safe.
|
|
276
382
|
"""
|
|
277
383
|
command = _createCommand(action, target, args)
|
|
278
|
-
with self.
|
|
279
|
-
self.
|
|
384
|
+
with self._bufferLock:
|
|
385
|
+
self._commandBuffer.append(command)
|
|
386
|
+
flush_now = len(self._commandBuffer) >= self._maxBufferSize
|
|
387
|
+
if flush_now and self._callbackActive.acquire(blocking=False):
|
|
388
|
+
self._callbackActive.release()
|
|
389
|
+
self._flushBuffer()
|
|
280
390
|
|
|
281
391
|
def sendQuery(self, action, target, args=None):
|
|
282
392
|
"""
|
|
283
393
|
Sends a command to GuiRenderer and blocks until a response is received.
|
|
284
394
|
Returns the response's values list. Thread-safe; multiple callers may
|
|
285
395
|
block concurrently — each is matched to its response by responseId.
|
|
396
|
+
|
|
397
|
+
Flushes any buffered fire-and-forget commands before sending the query
|
|
398
|
+
so that GuiRenderer processes them first and the query reflects up-to-date
|
|
399
|
+
state. The flush and query send are both performed under _sendLock so
|
|
400
|
+
no other thread can interleave a send between them.
|
|
286
401
|
"""
|
|
287
402
|
responseId = self._nextResponseId()
|
|
288
403
|
pendingSlot = {'event': threading.Event(), 'values': None}
|
|
@@ -292,8 +407,16 @@ class GuiHandler:
|
|
|
292
407
|
self._pendingResponses[responseId] = pendingSlot
|
|
293
408
|
|
|
294
409
|
command = _createCommand(action, target, args, responseId=responseId)
|
|
410
|
+
|
|
411
|
+
# grab any buffered commands, then send them + the query atomically as one list
|
|
412
|
+
with self._bufferLock:
|
|
413
|
+
batch = self._commandBuffer
|
|
414
|
+
self._commandBuffer = []
|
|
295
415
|
with self._sendLock:
|
|
296
|
-
|
|
416
|
+
try:
|
|
417
|
+
self.connection.send(batch + [command])
|
|
418
|
+
except (BrokenPipeError, OSError):
|
|
419
|
+
pass # pipe is gone; listener thread will release the pending slot via EOFError
|
|
297
420
|
|
|
298
421
|
pendingSlot['event'].wait() # block until the listener signals a response
|
|
299
422
|
|
|
@@ -302,25 +425,6 @@ class GuiHandler:
|
|
|
302
425
|
|
|
303
426
|
return pendingSlot['values']
|
|
304
427
|
|
|
305
|
-
# ── Developer utilities ───────────────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
def getBatchSize(self):
|
|
308
|
-
"""
|
|
309
|
-
Returns the current _BATCH_SIZE used by GuiRenderer's pipe-polling loop.
|
|
310
|
-
Not part of the public API — intended for developer tuning only.
|
|
311
|
-
"""
|
|
312
|
-
result = self.sendQuery('getBatchSize', None)
|
|
313
|
-
return result[0]
|
|
314
|
-
|
|
315
|
-
def setBatchSize(self, batchSize):
|
|
316
|
-
"""
|
|
317
|
-
Sets GuiRenderer's _BATCH_SIZE on the fly.
|
|
318
|
-
Higher values process more commands between Qt repaints (better throughput,
|
|
319
|
-
less responsive feel); lower values yield more frequent repaints.
|
|
320
|
-
Not part of the public API — intended for developer tuning only.
|
|
321
|
-
"""
|
|
322
|
-
self.sendCommand('setBatchSize', None, {'batchSize': batchSize})
|
|
323
|
-
|
|
324
428
|
# ── Events ────────────────────────────────────────────────────────────────
|
|
325
429
|
|
|
326
430
|
def registerEvent(self, objectId, eventType, callback):
|
|
@@ -336,10 +440,70 @@ class GuiHandler:
|
|
|
336
440
|
registrationArgs = {'objectId': objectId, 'eventType': eventType}
|
|
337
441
|
self.sendCommand('registerEvent', None, registrationArgs)
|
|
338
442
|
|
|
443
|
+
def _runDispatchThread(self):
|
|
444
|
+
"""
|
|
445
|
+
Background thread. Drains _eventQueue and runs event callbacks
|
|
446
|
+
sequentially. Decoupled from the listener thread so callbacks can call
|
|
447
|
+
sendQuery() without deadlocking the listener.
|
|
448
|
+
|
|
449
|
+
Coalesces consecutive position events (mouseMove, mouseDrag) for the same
|
|
450
|
+
target: when multiple are queued back-to-back, only the last one is
|
|
451
|
+
dispatched. This prevents lag when a callback involves a query round-trip
|
|
452
|
+
(e.g. intersects()) that causes events to accumulate faster than they are
|
|
453
|
+
consumed. All other event types (clicks, keys, enter/exit) are always
|
|
454
|
+
dispatched in full order.
|
|
455
|
+
|
|
456
|
+
Exits when it receives the None sentinel pushed by _listenForMessages.
|
|
457
|
+
"""
|
|
458
|
+
_COALESCE = frozenset({'mouseMove', 'mouseDrag'})
|
|
459
|
+
|
|
460
|
+
while True:
|
|
461
|
+
# block until at least one event is available
|
|
462
|
+
batch = [self._eventQueue.get()]
|
|
463
|
+
if batch[0] is None:
|
|
464
|
+
break
|
|
465
|
+
|
|
466
|
+
# drain any additional immediately-available events into the batch
|
|
467
|
+
try:
|
|
468
|
+
while True:
|
|
469
|
+
msg = self._eventQueue.get(block=False)
|
|
470
|
+
batch.append(msg)
|
|
471
|
+
if msg is None:
|
|
472
|
+
break # sentinel — process batch then exit
|
|
473
|
+
except queue.Empty:
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
# process the batch in order, coalescing consecutive position events
|
|
477
|
+
i = 0
|
|
478
|
+
while i < len(batch):
|
|
479
|
+
msg = batch[i]
|
|
480
|
+
if msg is None:
|
|
481
|
+
return # sentinel reached mid-batch
|
|
482
|
+
|
|
483
|
+
eventType = msg.get('type')
|
|
484
|
+
if eventType in _COALESCE:
|
|
485
|
+
target = msg.get('target')
|
|
486
|
+
# find the last consecutive event of the same type+target
|
|
487
|
+
j = i + 1
|
|
488
|
+
while (j < len(batch)
|
|
489
|
+
and batch[j] is not None
|
|
490
|
+
and batch[j].get('type') == eventType
|
|
491
|
+
and batch[j].get('target') == target):
|
|
492
|
+
j += 1
|
|
493
|
+
with self._callbackActive:
|
|
494
|
+
self._dispatchEvent(batch[j - 1]) # skip stale positions
|
|
495
|
+
self._flushBuffer()
|
|
496
|
+
i = j
|
|
497
|
+
else:
|
|
498
|
+
with self._callbackActive:
|
|
499
|
+
self._dispatchEvent(msg)
|
|
500
|
+
self._flushBuffer()
|
|
501
|
+
i += 1
|
|
502
|
+
|
|
339
503
|
def _dispatchEvent(self, eventDict):
|
|
340
504
|
"""
|
|
341
505
|
Looks up and calls the callback registered for the incoming event.
|
|
342
|
-
Called from the
|
|
506
|
+
Called from the dispatch thread.
|
|
343
507
|
"""
|
|
344
508
|
eventType = eventDict.get('type')
|
|
345
509
|
objectId = eventDict.get('target')
|
|
@@ -357,12 +521,16 @@ class GuiHandler:
|
|
|
357
521
|
def _shutdown(self):
|
|
358
522
|
"""
|
|
359
523
|
Signals the child to shut down and waits for it to exit.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
524
|
+
Stops the sender thread first so any remaining buffered commands are
|
|
525
|
+
flushed before the pipe closes. Closing the shutdown pipe delivers EOF
|
|
526
|
+
to the child's QSocketNotifier, which fires _onShutdownSignal on Qt's
|
|
527
|
+
main thread immediately — even if atexit does not run, since the OS
|
|
528
|
+
closes the pipe fd on process death.
|
|
363
529
|
Called automatically via atexit when the parent process exits.
|
|
364
530
|
"""
|
|
365
|
-
self.
|
|
531
|
+
self._senderStopEvent.set()
|
|
532
|
+
self._senderThread.join(timeout=1.0)
|
|
533
|
+
self._adminConn.close() # EOF on child's admin pipe triggers shutdown
|
|
366
534
|
self.childProcess.join(timeout=1.0)
|
|
367
535
|
|
|
368
536
|
|
|
@@ -383,8 +551,10 @@ class _NullHandler:
|
|
|
383
551
|
def sendCommand(self, action, target, args=None): pass
|
|
384
552
|
def sendQuery(self, action, target, args=None): return [0, 0, 0, 0]
|
|
385
553
|
def registerEvent(self, objectId, eventType, callback): pass
|
|
386
|
-
def
|
|
387
|
-
def
|
|
554
|
+
def getFlushRate(self): return _FLUSH_RATE
|
|
555
|
+
def setFlushRate(self, flushRate): pass
|
|
556
|
+
def getRate(self): return _FLUSH_RATE
|
|
557
|
+
def setRate(self, _rate): pass
|
|
388
558
|
|
|
389
559
|
|
|
390
560
|
def _createHandler():
|