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.
Files changed (95) hide show
  1. {creativepython-1.1.2/src/CreativePython.egg-info → creativepython-1.1.3}/PKG-INFO +1 -1
  2. {creativepython-1.1.2 → creativepython-1.1.3}/pyproject.toml +1 -1
  3. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/GuiHandler.py +236 -66
  4. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/GuiRenderer.py +371 -324
  5. {creativepython-1.1.2 → creativepython-1.1.3/src/CreativePython.egg-info}/PKG-INFO +1 -1
  6. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/SOURCES.txt +1 -0
  7. {creativepython-1.1.2 → creativepython-1.1.3}/src/gui.py +3 -3
  8. creativepython-1.1.3/tests/testHitTesting.py +28 -0
  9. {creativepython-1.1.2 → creativepython-1.1.3}/LICENSE +0 -0
  10. {creativepython-1.1.2 → creativepython-1.1.3}/LICENSE-PSF +0 -0
  11. {creativepython-1.1.2 → creativepython-1.1.3}/MANIFEST.in +0 -0
  12. {creativepython-1.1.2 → creativepython-1.1.3}/README.md +0 -0
  13. {creativepython-1.1.2 → creativepython-1.1.3}/setup.cfg +0 -0
  14. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/RealtimeAudioPlayer.py +0 -0
  15. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/__init__.py +0 -0
  16. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/Java-Comparison-Tests/advMetricRunner.pythonSurvey.py +0 -0
  17. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/Java-Comparison-Tests/compareMetrics_Java-Vs-Python.py +0 -0
  18. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/RunMetrics.py +0 -0
  19. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/Surveyor.py +0 -0
  20. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/__init__.py +0 -0
  21. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Confidence.py +0 -0
  22. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Contig.py +0 -0
  23. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/ExtendedNote.py +0 -0
  24. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Histogram.py +0 -0
  25. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Judgement.py +0 -0
  26. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/Measurement.py +0 -0
  27. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/PianoRoll.py +0 -0
  28. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/PianoRollOld.py +0 -0
  29. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/__init__.py +0 -0
  30. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_ExtendedNote.py +0 -0
  31. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_Histogram.py +0 -0
  32. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_Measurement.py +0 -0
  33. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_assertions.py +0 -0
  34. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_integration.py +0 -0
  35. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_quantization.py +0 -0
  36. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/data/test_PianoRoll_unit.py +0 -0
  37. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/Metric.py +0 -0
  38. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/ZipfMetrics.py +0 -0
  39. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/__init__.py +0 -0
  40. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordDensityMetric.py +0 -0
  41. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordDistanceMetric.py +0 -0
  42. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordMetric.py +0 -0
  43. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChordNormalizedMetric.py +0 -0
  44. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ChromaticToneMetric.py +0 -0
  45. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationMetric.py +0 -0
  46. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourBasslineDurationQuantizedMetric.py +0 -0
  47. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourBasslinePitchMetric.py +0 -0
  48. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationMetric.py +0 -0
  49. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyDurationQuantizedMetric.py +0 -0
  50. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/ContourMelodyPitchMetric.py +0 -0
  51. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationBigramMetric.py +0 -0
  52. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationDistanceMetric.py +0 -0
  53. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationMetric.py +0 -0
  54. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedBigramMetric.py +0 -0
  55. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedDistanceMetric.py +0 -0
  56. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/DurationQuantizedMetric.py +0 -0
  57. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/HarmonicBigramMetric.py +0 -0
  58. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/HarmonicConsonanceMetric.py +0 -0
  59. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/HarmonicIntervalMetric.py +0 -0
  60. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/MelodicBigramMetric.py +0 -0
  61. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/MelodicConsonanceMetric.py +0 -0
  62. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/MelodicIntervalMetric.py +0 -0
  63. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchDistanceMetric.py +0 -0
  64. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchDurationMetric.py +0 -0
  65. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchDurationQuantizedMetric.py +0 -0
  66. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/PitchMetric.py +0 -0
  67. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/RestMetric.py +0 -0
  68. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/__init__.py +0 -0
  69. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_DurationMetric.py +0 -0
  70. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_BasicIntervalsAndBigrams.py +0 -0
  71. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ChordsAndConsonance.py +0 -0
  72. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_ContoursAndChromatic.py +0 -0
  73. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_Metrics_QuantizedDurationsAndDistances.py +0 -0
  74. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_PitchMetric.py +0 -0
  75. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/simple/test_RestMetric.py +0 -0
  76. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/metrics/test_Metric.py +0 -0
  77. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/utilities/CSVWriter.py +0 -0
  78. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/utilities/PowerLawRandom.py +0 -0
  79. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/nevmuse/utilities/__init__.py +0 -0
  80. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython/notationRenderer.py +0 -0
  81. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/dependency_links.txt +0 -0
  82. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/requires.txt +0 -0
  83. {creativepython-1.1.2 → creativepython-1.1.3}/src/CreativePython.egg-info/top_level.txt +0 -0
  84. {creativepython-1.1.2 → creativepython-1.1.3}/src/bin/libportaudio.2.dylib +0 -0
  85. {creativepython-1.1.2 → creativepython-1.1.3}/src/iannix.py +0 -0
  86. {creativepython-1.1.2 → creativepython-1.1.3}/src/image.py +0 -0
  87. {creativepython-1.1.2 → creativepython-1.1.3}/src/markov.py +0 -0
  88. {creativepython-1.1.2 → creativepython-1.1.3}/src/midi.py +0 -0
  89. {creativepython-1.1.2 → creativepython-1.1.3}/src/music.py +0 -0
  90. {creativepython-1.1.2 → creativepython-1.1.3}/src/osc.py +0 -0
  91. {creativepython-1.1.2 → creativepython-1.1.3}/src/timer.py +0 -0
  92. {creativepython-1.1.2 → creativepython-1.1.3}/src/zipf.py +0 -0
  93. {creativepython-1.1.2 → creativepython-1.1.3}/tests/testAnimate.py +0 -0
  94. {creativepython-1.1.2 → creativepython-1.1.3}/tests/testPeer.py +0 -0
  95. {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.2
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.2"
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, shutdownReadConn, shutdownWriteConn):
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
- shutdownReadConn is the read end of the dedicated shutdown pipe; the child
71
- watches it with QSocketNotifier and shuts down when it becomes readable (EOF).
72
- shutdownWriteConn is the write end inherited from the parent (fork) or passed
73
- via pickling (forkserver/spawn); it is closed immediately so that only the
74
- parent's copy of the write end remains open — ensuring the child sees EOF when
75
- the parent closes its copy.
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
- shutdownWriteConn.close() # child must not hold the write end open
87
+ adminParentConn.close() # child must not hold the parent end open
82
88
 
83
- # Suppress macOS system noise (stale XPC connections inherited via fork)
84
- # that would confuse students. Redirect at the OS level (fd 2) so that
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, shutdownReadConn)
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
- # Detection: if tkinter has been imported into the current process, Cocoa is
136
- # live and we must avoid fork. Otherwise, fork is safe and avoids the
137
- # __main__ re-import problem that spawn/forkserver have.
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 'tkinter' in sys.modules:
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 one-directional shutdown pipe. The parent holds the write
154
- # end; the child holds the read end. Closing the write end in the parent
155
- # sends EOF to the child's read end, which Qt detects immediately via
156
- # QSocketNotifier independent of the command queue and independent of
157
- # whether atexit runs (the OS closes the fd automatically on process death).
158
- shutdownReadConn, shutdownWriteConn = multiprocessing.Pipe(duplex=False)
159
- self._shutdownPipe = shutdownWriteConn
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, shutdownReadConn, shutdownWriteConn),
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() # parent no longer needs the child end of the command pipe
191
- shutdownReadConn.close() # parent no longer needs the read end of the shutdown pipe
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 # thread exits automatically when the main process exits
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 invoke
238
- registered callbacks. Exits when the child closes the pipe (EOFError)
239
- and releases any sendQuery() callers still waiting for a response.
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._dispatchEvent(message)
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
- Sends a fire-and-forget command to GuiRenderer (no response expected).
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._sendLock:
279
- self.connection.send(command)
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
- self.connection.send(command)
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 listener thread; the callback runs on that thread.
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
- Closing the shutdown pipe delivers EOF to the child's QSocketNotifier,
361
- which fires _onShutdownSignal on Qt's main thread immediately even if
362
- atexit does not run, since the OS closes the pipe fd on process death.
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._shutdownPipe.close()
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 getBatchSize(self): return 0
387
- def setBatchSize(self, batchSize): pass
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():