CreativePython 1.1.2__tar.gz → 1.1.4__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.4}/PKG-INFO +1 -1
- {creativepython-1.1.2 → creativepython-1.1.4}/pyproject.toml +1 -1
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/GuiHandler.py +261 -79
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/GuiRenderer.py +1031 -721
- {creativepython-1.1.2 → creativepython-1.1.4/src/CreativePython.egg-info}/PKG-INFO +1 -1
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython.egg-info/SOURCES.txt +2 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/gui.py +23 -114
- creativepython-1.1.4/tests/testHitTesting.py +28 -0
- creativepython-1.1.4/tests/testToolTips.py +16 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/LICENSE +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/LICENSE-PSF +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/MANIFEST.in +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/README.md +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/setup.cfg +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/RealtimeAudioPlayer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/Java-Comparison-Tests/advMetricRunner.pythonSurvey.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/Java-Comparison-Tests/compareMetrics_Java-Vs-Python.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/RunMetrics.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/Surveyor.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/Confidence.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/Contig.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/ExtendedNote.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/Histogram.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/Judgement.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/Measurement.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/PianoRoll.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/PianoRollOld.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/test_ExtendedNote.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/test_Histogram.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/test_Measurement.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/test_PianoRoll_assertions.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/test_PianoRoll_integration.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/test_PianoRoll_quantization.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/data/test_PianoRoll_unit.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/Metric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/ZipfMetrics.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ChordDensityMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ChordDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ChordMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ChordNormalizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ChromaticToneMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ContourBasslinePitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyPitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/DurationBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/DurationDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/DurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/HarmonicBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/HarmonicConsonanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/HarmonicIntervalMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/MelodicBigramMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/MelodicConsonanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/MelodicIntervalMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/PitchDistanceMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/PitchDurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/PitchDurationQuantizedMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/PitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/RestMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/test_DurationMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_BasicIntervalsAndBigrams.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ChordsAndConsonance.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ContoursAndChromatic.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_QuantizedDurationsAndDistances.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/test_PitchMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/simple/test_RestMetric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/metrics/test_Metric.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/utilities/CSVWriter.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/utilities/PowerLawRandom.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/nevmuse/utilities/__init__.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython/notationRenderer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython.egg-info/dependency_links.txt +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython.egg-info/requires.txt +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/CreativePython.egg-info/top_level.txt +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/bin/libportaudio.2.dylib +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/iannix.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/image.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/markov.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/midi.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/music.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/osc.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/timer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/src/zipf.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/tests/testAnimate.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/tests/testPeer.py +0 -0
- {creativepython-1.1.2 → creativepython-1.1.4}/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.4
|
|
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.4"
|
|
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,36 +66,49 @@ def _createEvent(eventType, target, args=None):
|
|
|
61
66
|
# Child process entry point
|
|
62
67
|
#######################################################################################
|
|
63
68
|
|
|
64
|
-
def _launchRenderer(
|
|
69
|
+
def _launchRenderer(childCommandConnection, childPriorityConnection, parentPriorityConnection):
|
|
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
|
+
childPriorityConnection 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 (parentPriorityConnection), the child
|
|
78
|
+
receives EOF and initiates shutdown.
|
|
79
|
+
|
|
80
|
+
parentPriorityConnection 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
|
-
import sys, os
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
os.
|
|
89
|
-
sys.stderr =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
import sys, os, traceback
|
|
86
|
+
|
|
87
|
+
parentPriorityConnection.close() # child must not hold the parent end open
|
|
88
|
+
|
|
89
|
+
# Redirect stderr (fd 2) to a log file so both Python tracebacks and C-level
|
|
90
|
+
# output from Qt/Cocoa/XPC are captured for diagnostics.
|
|
91
|
+
# The log is removed on clean exit if nothing was written to it.
|
|
92
|
+
_logPath = os.path.join(os.getcwd(), "PENCIL_debug.log")
|
|
93
|
+
_logFile = open(_logPath, "w")
|
|
94
|
+
os.dup2(_logFile.fileno(), 2)
|
|
95
|
+
sys.stderr = _logFile
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
from CreativePython.GuiRenderer import GuiRenderer
|
|
99
|
+
renderer = GuiRenderer(childCommandConnection, childPriorityConnection)
|
|
100
|
+
renderer.run()
|
|
101
|
+
except Exception:
|
|
102
|
+
traceback.print_exc()
|
|
103
|
+
raise
|
|
104
|
+
finally:
|
|
105
|
+
_logFile.flush()
|
|
106
|
+
if os.path.getsize(_logPath) == 0:
|
|
107
|
+
try:
|
|
108
|
+
_logFile.close()
|
|
109
|
+
os.unlink(_logPath)
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
94
112
|
|
|
95
113
|
|
|
96
114
|
#######################################################################################
|
|
@@ -113,8 +131,8 @@ class GuiHandler:
|
|
|
113
131
|
Registers an atexit handler so the child is always cleaned up when
|
|
114
132
|
the parent process exits — no cleanup code required in gui.py.
|
|
115
133
|
"""
|
|
116
|
-
|
|
117
|
-
self.connection =
|
|
134
|
+
parentCommandConnection, childCommandConnection = multiprocessing.Pipe(duplex=True)
|
|
135
|
+
self.connection = parentCommandConnection
|
|
118
136
|
|
|
119
137
|
# Choose the spawn context based on the runtime environment:
|
|
120
138
|
#
|
|
@@ -132,31 +150,32 @@ class GuiHandler:
|
|
|
132
150
|
# which would re-run the user's script. Used on Windows where
|
|
133
151
|
# fork is unavailable.
|
|
134
152
|
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
153
|
+
# On macOS, fork() is unsafe whenever the parent has any CoreFoundation /
|
|
154
|
+
# Cocoa state — not just when tkinter is explicitly imported. The Python
|
|
155
|
+
# runtime on macOS initialises parts of CoreFoundation at startup, so a
|
|
156
|
+
# forked child that then creates a QApplication reliably triggers the
|
|
157
|
+
# "You MUST exec()" crash. forkserver avoids this by forking from a clean
|
|
158
|
+
# helper process that has never touched Cocoa.
|
|
159
|
+
# On Windows, spawn is required (no fork).
|
|
160
|
+
# Inside a PyInstaller frozen binary, forkserver can't work (sys.executable
|
|
161
|
+
# is the frozen app, not a Python interpreter), so spawn is used instead.
|
|
138
162
|
import sys
|
|
139
163
|
if sys.platform == 'win32':
|
|
140
164
|
ctx = multiprocessing.get_context('spawn')
|
|
141
165
|
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
166
|
ctx = multiprocessing.get_context('spawn')
|
|
148
|
-
elif '
|
|
167
|
+
elif sys.platform == 'darwin':
|
|
149
168
|
ctx = multiprocessing.get_context('forkserver')
|
|
150
169
|
else:
|
|
151
170
|
ctx = multiprocessing.get_context('fork')
|
|
152
171
|
|
|
153
|
-
# Dedicated
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
|
|
159
|
-
self.
|
|
172
|
+
# Dedicated duplex admin pipe. The parent holds parentPriorityConnection; the child
|
|
173
|
+
# holds childPriorityConnection. Admin commands (setRate, getRate) travel here,
|
|
174
|
+
# bypassing the command buffer entirely. Closing parentPriorityConnection sends EOF
|
|
175
|
+
# to the child, which Qt detects via QSocketNotifier and treats as shutdown —
|
|
176
|
+
# independent of the command queue and independent of whether atexit runs.
|
|
177
|
+
childPriorityConnection, parentPriorityConnection = multiprocessing.Pipe(duplex=True)
|
|
178
|
+
self._adminConn = parentPriorityConnection
|
|
160
179
|
|
|
161
180
|
# With 'spawn' on POSIX (fork+exec), the forked helper calls
|
|
162
181
|
# get_preparation_data() which reads sys.modules['__main__'].__file__.
|
|
@@ -177,7 +196,7 @@ class GuiHandler:
|
|
|
177
196
|
try:
|
|
178
197
|
self.childProcess = ctx.Process(
|
|
179
198
|
target = _launchRenderer,
|
|
180
|
-
args = (
|
|
199
|
+
args = (childCommandConnection, childPriorityConnection, parentPriorityConnection),
|
|
181
200
|
daemon = True # child is killed automatically if parent dies unexpectedly
|
|
182
201
|
)
|
|
183
202
|
self.childProcess.start()
|
|
@@ -187,8 +206,8 @@ class GuiHandler:
|
|
|
187
206
|
_main_mod.__file__ = _saved_file
|
|
188
207
|
if _saved_spec is not _UNSET:
|
|
189
208
|
_main_mod.__spec__ = _saved_spec
|
|
190
|
-
|
|
191
|
-
|
|
209
|
+
childCommandConnection.close() # parent no longer needs the child end of the command pipe
|
|
210
|
+
childPriorityConnection.close() # parent no longer needs the child end of the admin pipe
|
|
192
211
|
|
|
193
212
|
# responseId counter — each sendQuery() call gets a unique ID so the
|
|
194
213
|
# listener thread can route the response back to the correct caller
|
|
@@ -209,14 +228,51 @@ class GuiHandler:
|
|
|
209
228
|
# must acquire this lock before writing to the pipe
|
|
210
229
|
self._sendLock = threading.Lock()
|
|
211
230
|
|
|
231
|
+
# command buffer — fire-and-forget commands are queued here and sent as
|
|
232
|
+
# a single batch by the sender thread (or flushed early on a query)
|
|
233
|
+
self._commandBuffer = []
|
|
234
|
+
self._bufferLock = threading.Lock()
|
|
235
|
+
|
|
236
|
+
# flush rate - how often to flush the buffer
|
|
237
|
+
self._flushRate = _FLUSH_RATE
|
|
238
|
+
self._maxBufferSize = _MAX_BUFFER_SIZE
|
|
239
|
+
|
|
240
|
+
# callback active lock — held by the dispatch thread while an event callback
|
|
241
|
+
# is executing. The sender thread checks this non-blocking; if it can't
|
|
242
|
+
# acquire, it skips that flush tick so mid-callback commands don't travel as
|
|
243
|
+
# a partial batch. After the callback, the dispatch thread does one explicit
|
|
244
|
+
# flush to send everything the callback accumulated as a single pipe message.
|
|
245
|
+
self._callbackActive = threading.Lock()
|
|
246
|
+
|
|
247
|
+
# sender thread — wakes every 1/FLUSH_RATE seconds and flushes the buffer
|
|
248
|
+
self._senderStopEvent = threading.Event()
|
|
249
|
+
self._senderThread = threading.Thread(
|
|
250
|
+
target = self._runSenderThread,
|
|
251
|
+
daemon = True
|
|
252
|
+
)
|
|
253
|
+
self._senderThread.start()
|
|
254
|
+
|
|
255
|
+
# event queue — listener thread enqueues incoming events here; dispatch
|
|
256
|
+
# thread drains it and runs callbacks. Decoupling the two threads means
|
|
257
|
+
# callbacks can call sendQuery() without deadlocking the listener.
|
|
258
|
+
self._eventQueue = queue.SimpleQueue()
|
|
259
|
+
|
|
212
260
|
# single listener thread handles both responses and events, since both
|
|
213
261
|
# arrive on the same connection and reading from it on two threads would race
|
|
214
262
|
self._listenerThread = threading.Thread(
|
|
215
263
|
target = self._listenForMessages,
|
|
216
|
-
daemon = True
|
|
264
|
+
daemon = True
|
|
217
265
|
)
|
|
218
266
|
self._listenerThread.start()
|
|
219
267
|
|
|
268
|
+
# dispatch thread runs event callbacks sequentially off the event queue,
|
|
269
|
+
# keeping the listener thread free to receive query responses at all times
|
|
270
|
+
self._dispatchThread = threading.Thread(
|
|
271
|
+
target = self._runDispatchThread,
|
|
272
|
+
daemon = True
|
|
273
|
+
)
|
|
274
|
+
self._dispatchThread.start()
|
|
275
|
+
|
|
220
276
|
atexit.register(self._shutdown)
|
|
221
277
|
|
|
222
278
|
# ── Response ID ───────────────────────────────────────────────────────────
|
|
@@ -234,9 +290,10 @@ class GuiHandler:
|
|
|
234
290
|
def _listenForMessages(self):
|
|
235
291
|
"""
|
|
236
292
|
Background thread. Reads incoming messages from GuiRenderer and routes
|
|
237
|
-
each one: responses unblock waiting sendQuery() callers; events
|
|
238
|
-
|
|
239
|
-
|
|
293
|
+
each one: responses unblock waiting sendQuery() callers; events are
|
|
294
|
+
pushed onto _eventQueue for the dispatch thread. Exits when the child
|
|
295
|
+
closes the pipe (EOFError), releases any waiting sendQuery() callers,
|
|
296
|
+
and sends a None sentinel to stop the dispatch thread.
|
|
240
297
|
"""
|
|
241
298
|
while True:
|
|
242
299
|
try:
|
|
@@ -244,13 +301,14 @@ class GuiHandler:
|
|
|
244
301
|
if 'responseId' in message:
|
|
245
302
|
self._handleResponse(message)
|
|
246
303
|
elif 'type' in message:
|
|
247
|
-
self.
|
|
304
|
+
self._eventQueue.put(message)
|
|
248
305
|
except EOFError:
|
|
249
306
|
break
|
|
250
307
|
with self._pendingLock:
|
|
251
308
|
for slot in self._pendingResponses.values():
|
|
252
309
|
slot['values'] = ['shutdown']
|
|
253
310
|
slot['event'].set()
|
|
311
|
+
self._eventQueue.put(None) # sentinel: tell dispatch thread to stop
|
|
254
312
|
|
|
255
313
|
def _handleResponse(self, responseDict):
|
|
256
314
|
"""
|
|
@@ -267,22 +325,91 @@ class GuiHandler:
|
|
|
267
325
|
pendingSlot['values'] = values # store result before signalling
|
|
268
326
|
pendingSlot['event'].set() # unblock the waiting sendQuery() call
|
|
269
327
|
|
|
328
|
+
# ── Sender thread ─────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
def _runSenderThread(self):
|
|
331
|
+
"""
|
|
332
|
+
Background thread. Wakes every 1 / _flushRate seconds and flushes the
|
|
333
|
+
command buffer. Skips a tick if a callback is currently executing
|
|
334
|
+
(_callbackActive is held) so the callback's commands don't get split
|
|
335
|
+
across multiple pipe messages. Exits when _senderStopEvent is set.
|
|
336
|
+
"""
|
|
337
|
+
while not self._senderStopEvent.wait(timeout=1.0 / self._flushRate):
|
|
338
|
+
if self._callbackActive.acquire(blocking=False):
|
|
339
|
+
self._callbackActive.release()
|
|
340
|
+
self._flushBuffer()
|
|
341
|
+
# else: callback in progress — skip this tick
|
|
342
|
+
self._flushBuffer() # final drain on shutdown
|
|
343
|
+
|
|
344
|
+
def _flushBuffer(self):
|
|
345
|
+
"""
|
|
346
|
+
Sends the current command buffer as a single batch message, then resets
|
|
347
|
+
the buffer. Thread-safe; concurrent callers are safe — only one will
|
|
348
|
+
get a non-empty batch, the others are no-ops.
|
|
349
|
+
"""
|
|
350
|
+
with self._bufferLock:
|
|
351
|
+
batch = self._commandBuffer
|
|
352
|
+
if not batch:
|
|
353
|
+
return
|
|
354
|
+
self._commandBuffer = []
|
|
355
|
+
with self._sendLock:
|
|
356
|
+
try:
|
|
357
|
+
self.connection.send(batch)
|
|
358
|
+
except (BrokenPipeError, OSError):
|
|
359
|
+
self._senderStopEvent.set() # pipe is gone; stop the sender thread
|
|
360
|
+
|
|
361
|
+
def getFlushRate(self):
|
|
362
|
+
"""
|
|
363
|
+
Returns the current tick rate for flushing the command buffer.
|
|
364
|
+
"""
|
|
365
|
+
return self._flushRate
|
|
366
|
+
|
|
367
|
+
def setFlushRate(self, flushRate):
|
|
368
|
+
"""
|
|
369
|
+
Sets the current tick rate for flushing the command buffer.
|
|
370
|
+
"""
|
|
371
|
+
self._flushRate = flushRate
|
|
372
|
+
|
|
373
|
+
def getRate(self):
|
|
374
|
+
"""
|
|
375
|
+
Returns the current render rate (timer ticks per second) of the Qt renderer.
|
|
376
|
+
Sends directly on the admin pipe, bypassing the command buffer.
|
|
377
|
+
"""
|
|
378
|
+
self._adminConn.send({'action': 'getRate'})
|
|
379
|
+
return self._adminConn.recv()
|
|
380
|
+
|
|
381
|
+
def setRate(self, rate):
|
|
382
|
+
"""
|
|
383
|
+
Sets the render rate (timer ticks per second) of the Qt renderer.
|
|
384
|
+
Sends directly on the admin pipe, bypassing the command buffer.
|
|
385
|
+
"""
|
|
386
|
+
self._adminConn.send({'action': 'setRate', 'rate': rate})
|
|
387
|
+
|
|
270
388
|
# ── Sending ───────────────────────────────────────────────────────────────
|
|
271
389
|
|
|
272
390
|
def sendCommand(self, action, target, args=None):
|
|
273
391
|
"""
|
|
274
|
-
|
|
275
|
-
Thread-safe.
|
|
392
|
+
Queues a fire-and-forget command in the command buffer. The sender thread
|
|
393
|
+
flushes on each tick. Thread-safe.
|
|
276
394
|
"""
|
|
277
395
|
command = _createCommand(action, target, args)
|
|
278
|
-
with self.
|
|
279
|
-
self.
|
|
396
|
+
with self._bufferLock:
|
|
397
|
+
self._commandBuffer.append(command)
|
|
398
|
+
flush_now = len(self._commandBuffer) >= self._maxBufferSize
|
|
399
|
+
if flush_now and self._callbackActive.acquire(blocking=False):
|
|
400
|
+
self._callbackActive.release()
|
|
401
|
+
self._flushBuffer()
|
|
280
402
|
|
|
281
403
|
def sendQuery(self, action, target, args=None):
|
|
282
404
|
"""
|
|
283
405
|
Sends a command to GuiRenderer and blocks until a response is received.
|
|
284
406
|
Returns the response's values list. Thread-safe; multiple callers may
|
|
285
407
|
block concurrently — each is matched to its response by responseId.
|
|
408
|
+
|
|
409
|
+
Flushes any buffered fire-and-forget commands before sending the query
|
|
410
|
+
so that GuiRenderer processes them first and the query reflects up-to-date
|
|
411
|
+
state. The flush and query send are both performed under _sendLock so
|
|
412
|
+
no other thread can interleave a send between them.
|
|
286
413
|
"""
|
|
287
414
|
responseId = self._nextResponseId()
|
|
288
415
|
pendingSlot = {'event': threading.Event(), 'values': None}
|
|
@@ -292,8 +419,16 @@ class GuiHandler:
|
|
|
292
419
|
self._pendingResponses[responseId] = pendingSlot
|
|
293
420
|
|
|
294
421
|
command = _createCommand(action, target, args, responseId=responseId)
|
|
422
|
+
|
|
423
|
+
# grab any buffered commands, then send them + the query atomically as one list
|
|
424
|
+
with self._bufferLock:
|
|
425
|
+
batch = self._commandBuffer
|
|
426
|
+
self._commandBuffer = []
|
|
295
427
|
with self._sendLock:
|
|
296
|
-
|
|
428
|
+
try:
|
|
429
|
+
self.connection.send(batch + [command])
|
|
430
|
+
except (BrokenPipeError, OSError):
|
|
431
|
+
pass # pipe is gone; listener thread will release the pending slot via EOFError
|
|
297
432
|
|
|
298
433
|
pendingSlot['event'].wait() # block until the listener signals a response
|
|
299
434
|
|
|
@@ -302,25 +437,6 @@ class GuiHandler:
|
|
|
302
437
|
|
|
303
438
|
return pendingSlot['values']
|
|
304
439
|
|
|
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
440
|
# ── Events ────────────────────────────────────────────────────────────────
|
|
325
441
|
|
|
326
442
|
def registerEvent(self, objectId, eventType, callback):
|
|
@@ -336,10 +452,70 @@ class GuiHandler:
|
|
|
336
452
|
registrationArgs = {'objectId': objectId, 'eventType': eventType}
|
|
337
453
|
self.sendCommand('registerEvent', None, registrationArgs)
|
|
338
454
|
|
|
455
|
+
def _runDispatchThread(self):
|
|
456
|
+
"""
|
|
457
|
+
Background thread. Drains _eventQueue and runs event callbacks
|
|
458
|
+
sequentially. Decoupled from the listener thread so callbacks can call
|
|
459
|
+
sendQuery() without deadlocking the listener.
|
|
460
|
+
|
|
461
|
+
Coalesces consecutive position events (mouseMove, mouseDrag) for the same
|
|
462
|
+
target: when multiple are queued back-to-back, only the last one is
|
|
463
|
+
dispatched. This prevents lag when a callback involves a query round-trip
|
|
464
|
+
(e.g. intersects()) that causes events to accumulate faster than they are
|
|
465
|
+
consumed. All other event types (clicks, keys, enter/exit) are always
|
|
466
|
+
dispatched in full order.
|
|
467
|
+
|
|
468
|
+
Exits when it receives the None sentinel pushed by _listenForMessages.
|
|
469
|
+
"""
|
|
470
|
+
_COALESCE = frozenset({'mouseMove', 'mouseDrag'})
|
|
471
|
+
|
|
472
|
+
while True:
|
|
473
|
+
# block until at least one event is available
|
|
474
|
+
batch = [self._eventQueue.get()]
|
|
475
|
+
if batch[0] is None:
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
# drain any additional immediately-available events into the batch
|
|
479
|
+
try:
|
|
480
|
+
while True:
|
|
481
|
+
msg = self._eventQueue.get(block=False)
|
|
482
|
+
batch.append(msg)
|
|
483
|
+
if msg is None:
|
|
484
|
+
break # sentinel — process batch then exit
|
|
485
|
+
except queue.Empty:
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
# process the batch in order, coalescing consecutive position events
|
|
489
|
+
i = 0
|
|
490
|
+
while i < len(batch):
|
|
491
|
+
msg = batch[i]
|
|
492
|
+
if msg is None:
|
|
493
|
+
return # sentinel reached mid-batch
|
|
494
|
+
|
|
495
|
+
eventType = msg.get('type')
|
|
496
|
+
if eventType in _COALESCE:
|
|
497
|
+
target = msg.get('target')
|
|
498
|
+
# find the last consecutive event of the same type+target
|
|
499
|
+
j = i + 1
|
|
500
|
+
while (j < len(batch)
|
|
501
|
+
and batch[j] is not None
|
|
502
|
+
and batch[j].get('type') == eventType
|
|
503
|
+
and batch[j].get('target') == target):
|
|
504
|
+
j += 1
|
|
505
|
+
with self._callbackActive:
|
|
506
|
+
self._dispatchEvent(batch[j - 1]) # skip stale positions
|
|
507
|
+
self._flushBuffer()
|
|
508
|
+
i = j
|
|
509
|
+
else:
|
|
510
|
+
with self._callbackActive:
|
|
511
|
+
self._dispatchEvent(msg)
|
|
512
|
+
self._flushBuffer()
|
|
513
|
+
i += 1
|
|
514
|
+
|
|
339
515
|
def _dispatchEvent(self, eventDict):
|
|
340
516
|
"""
|
|
341
517
|
Looks up and calls the callback registered for the incoming event.
|
|
342
|
-
Called from the
|
|
518
|
+
Called from the dispatch thread.
|
|
343
519
|
"""
|
|
344
520
|
eventType = eventDict.get('type')
|
|
345
521
|
objectId = eventDict.get('target')
|
|
@@ -357,12 +533,16 @@ class GuiHandler:
|
|
|
357
533
|
def _shutdown(self):
|
|
358
534
|
"""
|
|
359
535
|
Signals the child to shut down and waits for it to exit.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
536
|
+
Stops the sender thread first so any remaining buffered commands are
|
|
537
|
+
flushed before the pipe closes. Closing the shutdown pipe delivers EOF
|
|
538
|
+
to the child's QSocketNotifier, which fires _onShutdownSignal on Qt's
|
|
539
|
+
main thread immediately — even if atexit does not run, since the OS
|
|
540
|
+
closes the pipe fd on process death.
|
|
363
541
|
Called automatically via atexit when the parent process exits.
|
|
364
542
|
"""
|
|
365
|
-
self.
|
|
543
|
+
self._senderStopEvent.set()
|
|
544
|
+
self._senderThread.join(timeout=1.0)
|
|
545
|
+
self._adminConn.close() # EOF on child's admin pipe triggers shutdown
|
|
366
546
|
self.childProcess.join(timeout=1.0)
|
|
367
547
|
|
|
368
548
|
|
|
@@ -383,8 +563,10 @@ class _NullHandler:
|
|
|
383
563
|
def sendCommand(self, action, target, args=None): pass
|
|
384
564
|
def sendQuery(self, action, target, args=None): return [0, 0, 0, 0]
|
|
385
565
|
def registerEvent(self, objectId, eventType, callback): pass
|
|
386
|
-
def
|
|
387
|
-
def
|
|
566
|
+
def getFlushRate(self): return _FLUSH_RATE
|
|
567
|
+
def setFlushRate(self, flushRate): pass
|
|
568
|
+
def getRate(self): return _FLUSH_RATE
|
|
569
|
+
def setRate(self, _rate): pass
|
|
388
570
|
|
|
389
571
|
|
|
390
572
|
def _createHandler():
|