CreativePython 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3222 @@
1
+ #######################################################################################
2
+ # GuiRenderer.py Version 1.0 12-Mar-2026
3
+ # Taj Ballinger, Bill Manaris
4
+ #######################################################################################
5
+ #
6
+ # GuiRenderer runs in the child process and owns the Qt event loop.
7
+ # It receives commands from GuiHandler via a Pipe, renders them with Qt,
8
+ # and sends back responses and events.
9
+ #
10
+ # This file is never imported by gui.py — only by the child process via the lazy
11
+ # import inside GuiHandler._launchRenderer(). Qt can therefore be
12
+ # imported normally at the top of this file without affecting the main interpreter.
13
+ #
14
+ # Mirror objects
15
+ # --------------
16
+ # Each gui.py class that creates Qt objects has a mirror class here
17
+ # (e.g. Display -> DisplayMirror, Rectangle -> RectangleMirror). Mirror objects are
18
+ # instantiated by 'create' commands and registered in GuiRenderer._objectRegistry
19
+ # under their objectId. Subsequent commands are dispatched to the mirror by objectId.
20
+ #
21
+ # Base classes
22
+ # ------------
23
+ # _DrawableMirror — position, size, rotation, hit-testing, visibility, tooltip
24
+ # _GraphicsMirror — color, fill, thickness (extends _DrawableMirror)
25
+ # Each concrete shape class extends _GraphicsMirror and may override _setSize.
26
+ #
27
+ # Command dispatch
28
+ # ----------------
29
+ # Each mirror class builds a _commandHandlers dict in __init__, mapping action
30
+ # names to handler methods. Handlers always take (args, responseId) so the
31
+ # dispatcher (handleCommand) stays uniform. Because Python resolves 'self.method'
32
+ # through the MRO at the time the dict is built, subclass overrides (e.g.
33
+ # RectangleMirror._setSize) are picked up automatically without extra wiring.
34
+ #
35
+ # Event handling
36
+ # --------------
37
+ # GuiHandler.registerEvent() stores a callback locally and sends a 'registerEvent'
38
+ # command here. GuiRenderer stores the (objectId, eventType) pair in
39
+ # _registeredEvents. The _GuiEventDispatcher (Phase 7) checks _registeredEvents
40
+ # before relaying any Qt event across the pipe, so only user-registered events
41
+ # generate inter-process traffic.
42
+ #
43
+ # See GuiHandler.py for the full protocol description.
44
+ #
45
+
46
+ import PySide6.QtWidgets as QtWidgets
47
+ import PySide6.QtCore as QtCore
48
+ import PySide6.QtGui as QtGui
49
+
50
+ from CreativePython.GuiHandler import _createCommand, _createResponse, _createEvent
51
+
52
+ # Arc style constants — must match gui.py's PIE, OPEN, CHORD values.
53
+ # Defined here to avoid importing gui.py in the child process.
54
+ _PIE = 0
55
+ _OPEN = 1
56
+ _CHORD = 2
57
+
58
+
59
+ #######################################################################################
60
+ # GuiRenderer
61
+ #######################################################################################
62
+
63
+ class GuiRenderer:
64
+ """
65
+ Owns the Qt event loop and all Qt objects in the child process.
66
+ Receives commands from GuiHandler via a Pipe, renders them, and sends
67
+ back responses and events.
68
+
69
+ Command routing
70
+ ---------------
71
+ 'create' commands instantiate a new mirror object (e.g. DisplayMirror, RectangleMirror)
72
+ and register it in _objectRegistry under the objectId in the command's 'target'
73
+ field. All subsequent commands for that object are routed to it by objectId.
74
+ Built-in actions ('ping', 'shutdown', 'registerEvent') are handled directly.
75
+ Unknown targets and unknown actions are silently ignored.
76
+ """
77
+
78
+ def __init__(self, childConn):
79
+ """
80
+ Creates the QApplication and initializes all registries.
81
+ """
82
+ self.connection = childConn
83
+
84
+ # QApplication must be created first; it owns the Qt event loop.
85
+ self._app = QtWidgets.QApplication([])
86
+
87
+ # Do not quit when the last window is closed. The default behaviour
88
+ # (quitOnLastWindowClosed = True) would exit the child process the
89
+ # moment the user closes a Display, breaking the IPC pipe and making
90
+ # it impossible to open new Displays without restarting Python.
91
+ # The child only exits when the parent sends a 'shutdown' command or
92
+ # kills the process directly.
93
+ self._app.setQuitOnLastWindowClosed(False)
94
+
95
+ # Qt installs a SIGTERM handler that drains the event queue before
96
+ # exiting (graceful shutdown). When the parent kills this process,
97
+ # we want immediate exit — override Qt's handler after QApplication
98
+ # is constructed so ours takes precedence.
99
+ import signal, os as _os
100
+ signal.signal(signal.SIGTERM, lambda sig, frame: _os._exit(0))
101
+
102
+ # maps objectId (int) -> mirror object (e.g. DisplayMirror, RectangleMirror)
103
+ self._objectRegistry = {}
104
+
105
+ # set of (objectId, eventType) pairs registered by the parent process.
106
+ # _GuiEventDispatcher (Phase 7) checks this before sending events across the pipe.
107
+ self._registeredEvents = set()
108
+
109
+ def run(self):
110
+ """
111
+ Starts the pipe-polling timer and enters Qt's event loop.
112
+ The QTimer fires _pollPipe() every 8ms (~120 Hz), draining any pending
113
+ commands without blocking the Qt event loop between frames.
114
+ """
115
+ self._timer = QtCore.QTimer()
116
+ self._timer.timeout.connect(self._pollPipe)
117
+ self._timer.start(8)
118
+ self._app.exec()
119
+
120
+ _polling = False # reentrancy guard
121
+
122
+ # # ── Option A: no batching (drain entire queue before rendering) ─────────
123
+ # def _pollPipe(self):
124
+ # """
125
+ # Drains all pending commands before returning to Qt's event loop.
126
+ # """
127
+ # while self.connection.poll():
128
+ # message = self.connection.recv()
129
+ # keepRunning = self._routeCommand(message)
130
+ # if not keepRunning:
131
+ # break
132
+
133
+ # ── Option B: fixed batch (let Qt render every N commands) ──────────────
134
+ _BATCH_SIZE = 10
135
+
136
+ def _pollPipe(self):
137
+ """
138
+ Drains pending commands, letting Qt process events every _BATCH_SIZE
139
+ commands so the display updates incrementally during large batches.
140
+ """
141
+ if self._polling:
142
+ return # processEvents() fired the timer again — skip
143
+ self._polling = True
144
+ try:
145
+ count = 0
146
+ while self.connection.poll():
147
+ message = self.connection.recv()
148
+ keepRunning = self._routeCommand(message)
149
+ if not keepRunning:
150
+ break
151
+ count += 1
152
+ if count % self._BATCH_SIZE == 0:
153
+ self._app.processEvents()
154
+ finally:
155
+ self._polling = False
156
+
157
+ # # ── Option C: adaptive batch (scales with scene complexity) ─────────────
158
+ # _BATCH_MIN = 10 # batch size when scene is empty
159
+ # _BATCH_MAX = 1000 # batch size ceiling
160
+ # _BATCH_PER = 25 # add this many to the batch per 1000 objects in scene
161
+
162
+ # def _pollPipe(self):
163
+ # """
164
+ # Drains pending commands, letting Qt process events at adaptive
165
+ # intervals. Small scenes get frequent updates (responsive feel);
166
+ # large scenes batch more commands between repaints (less overhead
167
+ # from increasingly expensive repaints).
168
+ # """
169
+ # if self._polling:
170
+ # return # processEvents() fired the timer again — skip
171
+ # self._polling = True
172
+ # try:
173
+ # objectCount = len(self._objectRegistry)
174
+ # batchSize = min(self._BATCH_MAX,
175
+ # self._BATCH_MIN + objectCount * self._BATCH_PER)
176
+ # count = 0
177
+ # while self.connection.poll():
178
+ # message = self.connection.recv()
179
+ # keepRunning = self._routeCommand(message)
180
+ # if not keepRunning:
181
+ # break
182
+ # count += 1
183
+ # if count % batchSize == 0:
184
+ # self._app.processEvents()
185
+ # finally:
186
+ # self._polling = False
187
+
188
+ def _routeCommand(self, commandDict):
189
+ """
190
+ Routes an incoming command to the appropriate handler.
191
+ Returns False if the renderer should shut down, True otherwise.
192
+ """
193
+ action = commandDict.get('action')
194
+ target = commandDict.get('target')
195
+ args = commandDict.get('args', {})
196
+ responseId = commandDict.get('responseId')
197
+
198
+ # ── Built-in actions ──────────────────────────────────────────────────
199
+ if action == 'shutdown':
200
+ self._handleShutdown()
201
+ return False
202
+
203
+ elif action == 'ping':
204
+ response = _createResponse(responseId, ['pong'])
205
+ self.connection.send(response)
206
+
207
+ elif action == 'registerEvent':
208
+ objectId = args.get('objectId')
209
+ eventType = args.get('eventType')
210
+ self._registeredEvents.add((objectId, eventType))
211
+
212
+ elif action == 'unregisterEvent':
213
+ objectId = args.get('objectId')
214
+ eventType = args.get('eventType')
215
+ self._registeredEvents.discard((objectId, eventType))
216
+
217
+ elif action == 'getBatchSize':
218
+ response = _createResponse(responseId, [self._BATCH_SIZE])
219
+ self.connection.send(response)
220
+
221
+ elif action == 'setBatchSize':
222
+ self._BATCH_SIZE = int(args.get('batchSize', self._BATCH_SIZE))
223
+
224
+ elif action == 'fireTestEvent':
225
+ eventType = args.get('eventType', 'testEvent')
226
+ eventTarget = args.get('target', 0)
227
+ eventArgs = args.get('eventArgs', {})
228
+ self.sendEvent(eventType, eventTarget, eventArgs)
229
+
230
+ elif action == 'colorDialog':
231
+ import sys, subprocess
232
+ initial = args.get('initial', [0, 0, 0, 255])
233
+ r, g, b, a = initial
234
+
235
+ if sys.platform == 'darwin':
236
+ # On macOS, showing any dialog (native or Qt) while a Qt window is
237
+ # on screen triggers Metal blit-shader compilation in Qt's window
238
+ # backing store. On Apple Silicon (AGXMetalG15X_M1) this compilation
239
+ # aborts at the GPU driver level (SIGABRT), killing the child process.
240
+ #
241
+ # Fix: run the color picker as a completely separate process via
242
+ # osascript. It has its own Metal context and no interaction with
243
+ # Qt's rendering pipeline. AppleScript returns 16-bit components
244
+ # (0-65535); we rescale to 8-bit (0-255).
245
+ r16 = int(r / 255 * 65535 + 0.5)
246
+ g16 = int(g / 255 * 65535 + 0.5)
247
+ b16 = int(b / 255 * 65535 + 0.5)
248
+ script = f'choose color default color {{{r16}, {g16}, {b16}}}'
249
+ proc = subprocess.run(['osascript', '-e', script],
250
+ capture_output=True, text=True)
251
+ if proc.returncode == 0:
252
+ parts = proc.stdout.strip().strip('{}').split(', ')
253
+ result = [int(int(p) / 65535 * 255 + 0.5) for p in parts]
254
+ else:
255
+ result = [r, g, b] # user cancelled
256
+ else:
257
+ # On Windows and Linux, QColorDialog works without issue.
258
+ initialColor = QtGui.QColor(r, g, b, a)
259
+ chosen = QtWidgets.QColorDialog.getColor(initialColor)
260
+ if chosen.isValid():
261
+ result = [chosen.red(), chosen.green(), chosen.blue()]
262
+ else:
263
+ result = [r, g, b] # user cancelled
264
+ response = _createResponse(responseId, result)
265
+ self.connection.send(response)
266
+
267
+ # ── Object creation ───────────────────────────────────────────────────
268
+ elif action == 'create':
269
+ objectType = args.get('type')
270
+ objectId = target # objectId is pre-assigned by the parent process
271
+
272
+ if objectType == 'Display':
273
+ mirror = DisplayMirror(objectId, args, self)
274
+ self._objectRegistry[objectId] = mirror
275
+
276
+ elif objectType == 'Rectangle':
277
+ mirror = RectangleMirror(objectId, args, self)
278
+ self._objectRegistry[objectId] = mirror
279
+
280
+ elif objectType == 'Oval':
281
+ mirror = OvalMirror(objectId, args, self)
282
+ self._objectRegistry[objectId] = mirror
283
+
284
+ elif objectType == 'Arc':
285
+ mirror = ArcMirror(objectId, args, self)
286
+ self._objectRegistry[objectId] = mirror
287
+
288
+ elif objectType == 'Polyline':
289
+ mirror = PolylineMirror(objectId, args, self)
290
+ self._objectRegistry[objectId] = mirror
291
+
292
+ elif objectType == 'Line':
293
+ mirror = LineMirror(objectId, args, self)
294
+ self._objectRegistry[objectId] = mirror
295
+
296
+ elif objectType == 'Polygon':
297
+ mirror = PolygonMirror(objectId, args, self)
298
+ self._objectRegistry[objectId] = mirror
299
+
300
+ elif objectType == 'Icon':
301
+ mirror = IconMirror(objectId, args, self)
302
+ self._objectRegistry[objectId] = mirror
303
+
304
+ elif objectType == 'Label':
305
+ mirror = LabelMirror(objectId, args, self)
306
+ self._objectRegistry[objectId] = mirror
307
+
308
+ elif objectType == 'Group':
309
+ mirror = GroupMirror(objectId, args, self)
310
+ self._objectRegistry[objectId] = mirror
311
+
312
+ elif objectType == 'Button':
313
+ mirror = ButtonMirror(objectId, args, self)
314
+ self._objectRegistry[objectId] = mirror
315
+
316
+ elif objectType == 'CheckBox':
317
+ mirror = CheckBoxMirror(objectId, args, self)
318
+ self._objectRegistry[objectId] = mirror
319
+
320
+ elif objectType == 'Slider':
321
+ mirror = SliderMirror(objectId, args, self)
322
+ self._objectRegistry[objectId] = mirror
323
+
324
+ elif objectType == 'DropDownList':
325
+ mirror = DropDownListMirror(objectId, args, self)
326
+ self._objectRegistry[objectId] = mirror
327
+
328
+ elif objectType == 'TextField':
329
+ mirror = TextFieldMirror(objectId, args, self)
330
+ self._objectRegistry[objectId] = mirror
331
+
332
+ elif objectType == 'TextArea':
333
+ mirror = TextAreaMirror(objectId, args, self)
334
+ self._objectRegistry[objectId] = mirror
335
+
336
+ elif objectType == 'Menu':
337
+ mirror = MenuMirror(objectId, args, self)
338
+ self._objectRegistry[objectId] = mirror
339
+
340
+ # ── Mirror object actions ─────────────────────────────────────────────
341
+ else:
342
+ mirrorObject = self._objectRegistry.get(target)
343
+ if mirrorObject is not None:
344
+ mirrorObject.handleCommand(action, args, responseId)
345
+ # unknown targets are silently ignored
346
+
347
+ return True
348
+
349
+ def sendResponse(self, responseId, values=None):
350
+ """Sends a response back to the parent process."""
351
+ response = _createResponse(responseId, values)
352
+ self.connection.send(response)
353
+
354
+ def sendEvent(self, eventType, objectId, args=None):
355
+ """Sends an event to the parent process."""
356
+ event = _createEvent(eventType, objectId, args)
357
+ self.connection.send(event)
358
+
359
+ def _handleShutdown(self):
360
+ """Stops the polling timer and quits the Qt event loop."""
361
+ self._timer.stop()
362
+ self._app.quit()
363
+
364
+
365
+ #######################################################################################
366
+ # Qt helper classes
367
+ #######################################################################################
368
+
369
+ class QtDisplay(QtWidgets.QMainWindow):
370
+ """
371
+ QMainWindow subclass used by DisplayMirror.
372
+ Overrides closeEvent so DisplayMirror can send a 'displayClose' event to the
373
+ parent process when the user closes the window.
374
+ """
375
+
376
+ def __init__(self, owner):
377
+ super().__init__()
378
+ self._owner = owner # the DisplayMirror that owns this window
379
+
380
+ def closeEvent(self, event):
381
+ self._owner.onWindowClose()
382
+ event.accept()
383
+
384
+
385
+ #######################################################################################
386
+ # _GuiEventDispatcher — Qt event filter for mouse/keyboard events
387
+ #######################################################################################
388
+
389
+ class _GuiEventDispatcher(QtCore.QObject):
390
+ """
391
+ Intercepts Qt mouse and keyboard events on a DisplayMirror's view/viewport,
392
+ performs hit testing against the display's _itemList, and sends matching
393
+ events across the pipe to GuiHandler for callback delivery.
394
+
395
+ One instance per DisplayMirror, installed as an event filter on _view (keys)
396
+ and _view.viewport() (mouse).
397
+ """
398
+
399
+ _CLICK_THRESHOLD = 5 # max pixel movement for mouseDown+mouseUp to count as a click
400
+
401
+ def __init__(self, displayMirror):
402
+ super().__init__()
403
+ self._display = displayMirror
404
+ self._guiRenderer = displayMirror._guiRenderer
405
+ self._draggingItems = [] # list of objectIds being dragged (set on mouseDown)
406
+ self._lastMouseDown = None # (x, y) at last mouseDown
407
+ self._lastMouseMove = None # (x, y) at last mouseMove
408
+ self._itemsUnderMouse = set() # set of objectIds currently under mouse
409
+
410
+ # install as event filter on viewport (mouse events) and view (key events)
411
+ displayMirror._view.viewport().installEventFilter(self)
412
+ displayMirror._view.installEventFilter(self)
413
+
414
+ # map Qt event types to handler methods
415
+ self._mouseHandlers = {
416
+ QtCore.QEvent.Type.MouseButtonPress: self._handleMousePress,
417
+ QtCore.QEvent.Type.MouseButtonRelease: self._handleMouseRelease,
418
+ QtCore.QEvent.Type.MouseMove: self._handleMouseMove,
419
+ QtCore.QEvent.Type.Enter: self._handleMouseEnter,
420
+ QtCore.QEvent.Type.Leave: self._handleMouseLeave,
421
+ }
422
+ self._keyHandlers = {
423
+ QtCore.QEvent.Type.KeyPress: self._handleKeyPress,
424
+ QtCore.QEvent.Type.KeyRelease: self._handleKeyRelease,
425
+ }
426
+
427
+ # ── eventFilter (Qt override) ─────────────────────────────────────────────
428
+
429
+ def eventFilter(self, obj, qEvent):
430
+ """
431
+ Qt event filter callback. Routes mouse events from the viewport and
432
+ key events from the view to the appropriate handler.
433
+ """
434
+ try:
435
+ if obj is self._display._view.viewport():
436
+ handler = self._mouseHandlers.get(qEvent.type())
437
+ if handler is not None:
438
+ x, y = self._extractMouseCoords(qEvent)
439
+ return handler(x, y)
440
+
441
+ elif obj is self._display._view:
442
+ handler = self._keyHandlers.get(qEvent.type())
443
+ if handler is not None:
444
+ if not qEvent.isAutoRepeat():
445
+ key = qEvent.key()
446
+ char = qEvent.text() if qEvent.text() else ""
447
+ return handler(key, char)
448
+ except RuntimeError:
449
+ pass # C++ object deleted during shutdown
450
+
451
+ return False
452
+
453
+ def _extractMouseCoords(self, qEvent):
454
+ """Extracts integer (x, y) coordinates from a QMouseEvent."""
455
+ if hasattr(qEvent, 'position') and callable(qEvent.position):
456
+ return int(qEvent.position().x()), int(qEvent.position().y())
457
+ elif self._lastMouseMove is not None:
458
+ return self._lastMouseMove
459
+ else:
460
+ return 0, 0
461
+
462
+ # ── Hit testing ────────────────────────────────────────────────────────────
463
+
464
+ def _allHittableItems(self, itemList=None):
465
+ """
466
+ Recursively walks a display's _itemList, descending into GroupMirrors.
467
+ Yields items in front-to-back order.
468
+ For Groups, yields children first, then the Group itself.
469
+ """
470
+ if itemList is None:
471
+ itemList = self._display._itemList
472
+ for item in itemList:
473
+ if item._isControl:
474
+ continue
475
+ if isinstance(item, GroupMirror):
476
+ # yield children first (they are in front of the group)
477
+ yield from self._allHittableItems(item._itemList)
478
+ # then yield the group itself
479
+ yield item
480
+ else:
481
+ yield item
482
+
483
+ def _hitTestCascade(self, eventType, x, y):
484
+ """
485
+ Returns an ordered list of objectIds to notify (deepest child first,
486
+ then ancestor Groups). Only includes items registered for eventType.
487
+ """
488
+ point = QtCore.QPointF(x, y)
489
+ targets = []
490
+ for item in self._allHittableItems():
491
+ localPoint = item._qObject.mapFromScene(point)
492
+ if item._qObject.contains(localPoint):
493
+ if (item.objectId, eventType) in self._guiRenderer._registeredEvents:
494
+ targets.append(item.objectId)
495
+ return targets
496
+
497
+ def _sendIfRegistered(self, eventType, objectId, args):
498
+ """Sends an event across the pipe if (objectId, eventType) is registered."""
499
+ if (objectId, eventType) in self._guiRenderer._registeredEvents:
500
+ self._guiRenderer.sendEvent(eventType, objectId, args)
501
+
502
+ # ── Mouse handlers ─────────────────────────────────────────────────────────
503
+
504
+ def _handleMousePress(self, x, y):
505
+ self._lastMouseDown = (x, y)
506
+ args = [x, y]
507
+
508
+ # find all items registered for mouseDrag (for future drag events)
509
+ self._draggingItems = self._hitTestCascade('mouseDrag', x, y)
510
+
511
+ # mouseDown: hit test and deliver to all matching items + Display
512
+ targets = self._hitTestCascade('mouseDown', x, y)
513
+ for targetId in targets:
514
+ self._sendIfRegistered('mouseDown', targetId, args)
515
+ self._sendIfRegistered('mouseDown', self._display.objectId, args)
516
+ return False
517
+
518
+ def _handleMouseRelease(self, x, y):
519
+ args = [x, y]
520
+
521
+ # check if this is a click (mouse moved less than threshold)
522
+ isClick = False
523
+ if self._lastMouseDown is not None:
524
+ dx = abs(x - self._lastMouseDown[0])
525
+ dy = abs(y - self._lastMouseDown[1])
526
+ if dx <= self._CLICK_THRESHOLD and dy <= self._CLICK_THRESHOLD:
527
+ isClick = True
528
+
529
+ self._lastMouseDown = None
530
+ self._draggingItems = []
531
+
532
+ # mouseUp: hit test and deliver
533
+ targets = self._hitTestCascade('mouseUp', x, y)
534
+ for targetId in targets:
535
+ self._sendIfRegistered('mouseUp', targetId, args)
536
+ self._sendIfRegistered('mouseUp', self._display.objectId, args)
537
+
538
+ # mouseClick: only if movement was under threshold
539
+ if isClick:
540
+ clickTargets = self._hitTestCascade('mouseClick', x, y)
541
+ for targetId in clickTargets:
542
+ self._sendIfRegistered('mouseClick', targetId, args)
543
+ self._sendIfRegistered('mouseClick', self._display.objectId, args)
544
+
545
+ return False
546
+
547
+ def _handleMouseMove(self, x, y):
548
+ self._lastMouseMove = (x, y)
549
+ args = [x, y]
550
+
551
+ if self._lastMouseDown is None:
552
+ # mouseMove (button up): hit test all matching items
553
+ targets = self._hitTestCascade('mouseMove', x, y)
554
+ for targetId in targets:
555
+ self._sendIfRegistered('mouseMove', targetId, args)
556
+ self._sendIfRegistered('mouseMove', self._display.objectId, args)
557
+ else:
558
+ # mouseDrag (button down): deliver to all drag items from mouseDown
559
+ for targetId in self._draggingItems:
560
+ self._sendIfRegistered('mouseDrag', targetId, args)
561
+ self._sendIfRegistered('mouseDrag', self._display.objectId, args)
562
+
563
+ # mouseEnter/Exit: only iterate if any item is registered for these events
564
+ self._updateEnterExit(x, y)
565
+
566
+ return False
567
+
568
+ def _updateEnterExit(self, x, y):
569
+ """Tracks which items are under the mouse and sends enter/exit events."""
570
+ # optimization: skip if no items are registered for enter or exit
571
+ hasEnterExit = False
572
+ for item in self._allHittableItems():
573
+ oid = item.objectId
574
+ if ((oid, 'mouseEnter') in self._guiRenderer._registeredEvents or
575
+ (oid, 'mouseExit') in self._guiRenderer._registeredEvents):
576
+ hasEnterExit = True
577
+ break
578
+ if not hasEnterExit:
579
+ return
580
+
581
+ # determine which items are under the mouse now
582
+ point = QtCore.QPointF(x, y)
583
+ itemsNow = set()
584
+ for item in self._allHittableItems():
585
+ localPoint = item._qObject.mapFromScene(point)
586
+ if item._qObject.contains(localPoint):
587
+ itemsNow.add(item.objectId)
588
+
589
+ entered = itemsNow - self._itemsUnderMouse
590
+ exited = self._itemsUnderMouse - itemsNow
591
+ self._itemsUnderMouse = itemsNow
592
+
593
+ args = [x, y]
594
+ for objectId in entered:
595
+ self._sendIfRegistered('mouseEnter', objectId, args)
596
+ for objectId in exited:
597
+ self._sendIfRegistered('mouseExit', objectId, args)
598
+
599
+ def _handleMouseEnter(self, x, y):
600
+ """Mouse entered the Display viewport — deliver mouseEnter to the Display."""
601
+ self._sendIfRegistered('mouseEnter', self._display.objectId, [x, y])
602
+ return False
603
+
604
+ def _handleMouseLeave(self, x, y):
605
+ """Mouse left the Display viewport — deliver mouseExit to the Display."""
606
+ self._sendIfRegistered('mouseExit', self._display.objectId, [x, y])
607
+ return False
608
+
609
+ # ── Key handlers ───────────────────────────────────────────────────────────
610
+
611
+ def _handleKeyPress(self, key, char):
612
+ """Delivers keyDown to ALL registered items (including grouped) + Display, then keyType."""
613
+ registered = self._guiRenderer._registeredEvents
614
+
615
+ # keyDown: deliver to all registered items
616
+ for item in self._allHittableItems():
617
+ if (item.objectId, 'keyDown') in registered:
618
+ self._guiRenderer.sendEvent('keyDown', item.objectId, [key])
619
+ self._sendIfRegistered('keyDown', self._display.objectId, [key])
620
+
621
+ # keyType: deliver to all registered items (char, not key code)
622
+ if char:
623
+ for item in self._allHittableItems():
624
+ if (item.objectId, 'keyType') in registered:
625
+ self._guiRenderer.sendEvent('keyType', item.objectId, [char])
626
+ self._sendIfRegistered('keyType', self._display.objectId, [char])
627
+
628
+ return False
629
+
630
+ def _handleKeyRelease(self, key, char):
631
+ """Delivers keyUp to ALL registered items (including grouped) + Display."""
632
+ registered = self._guiRenderer._registeredEvents
633
+
634
+ for item in self._allHittableItems():
635
+ if (item.objectId, 'keyUp') in registered:
636
+ self._guiRenderer.sendEvent('keyUp', item.objectId, [key])
637
+ self._sendIfRegistered('keyUp', self._display.objectId, [key])
638
+
639
+ return False
640
+
641
+
642
+ #######################################################################################
643
+ # DisplayMirror — mirror of gui.py's Display
644
+ #######################################################################################
645
+
646
+ class DisplayMirror:
647
+ """
648
+ Mirror of gui.py's Display class. Owns all Qt objects for one display window.
649
+
650
+ Created by GuiRenderer when it receives a 'create' command with type='Display'.
651
+ All subsequent operations arrive as commands routed through handleCommand().
652
+
653
+ add / addOrder / remove
654
+ -----------------------
655
+ Items on a Display are tracked in _itemList (front = top z-order). Qt z-order
656
+ is maintained as a float _qZValue on each mirror object so that insertions only
657
+ require averaging two neighbours rather than renumbering the entire list.
658
+ """
659
+
660
+ def __init__(self, objectId, args, guiRenderer):
661
+ """
662
+ Creates the Qt window, scene, view, and OpenGL viewport for one Display.
663
+ """
664
+ self.objectId = objectId
665
+ self._guiRenderer = guiRenderer
666
+ self._itemList = []
667
+ self._toolTipText = None
668
+ self._showCoords = False
669
+
670
+ title = args.get('title', '')
671
+ width = args.get('width', 600)
672
+ height = args.get('height', 400)
673
+ x = args.get('x', 0)
674
+ y = args.get('y', 50)
675
+ color = args.get('color', [255, 255, 255, 255])
676
+
677
+ self._window = QtDisplay(self)
678
+ self._window.setWindowTitle(title)
679
+ self._window.setGeometry(x, y, width, height)
680
+ self._window.setFixedSize(width, height)
681
+ contextPolicy = QtCore.Qt.ContextMenuPolicy.CustomContextMenu
682
+ self._window.setContextMenuPolicy(contextPolicy)
683
+ self._window.show()
684
+
685
+ # Qt 6's default software rasterizer (raster engine) is sufficient for
686
+ # 2D educational graphics and works consistently across all platforms.
687
+ # QOpenGLWidget was previously used here for hardware acceleration, but
688
+ # on macOS its OpenGL→Metal bridge conflicted with native AppKit dialogs
689
+ # (NSColorPicker, etc.) that also use Metal, causing the GPU shader
690
+ # compiler to abort (SIGABRT in AGXMetal). Removing it eliminates that
691
+ # crash, simplifies the code, and gives identical behavior on every OS.
692
+ # The OS window manager still handles GPU compositing of the final window.
693
+ self._scene = QtWidgets.QGraphicsScene(0, 0, width, height)
694
+ self._view = QtWidgets.QGraphicsView(self._scene)
695
+ self._window.setCentralWidget(self._view)
696
+
697
+ noIndex = QtWidgets.QGraphicsScene.ItemIndexMethod.NoIndex
698
+ minimalUpdate = QtWidgets.QGraphicsView.ViewportUpdateMode.MinimalViewportUpdate
699
+ hoverTracking = QtCore.Qt.WidgetAttribute.WA_Hover
700
+ mouseTracking = QtCore.Qt.WidgetAttribute.WA_MouseTracking
701
+ scrollOff = QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff
702
+ antiAlias = QtGui.QPainter.RenderHint.Antialiasing
703
+ smoothPixmap = QtGui.QPainter.RenderHint.SmoothPixmapTransform
704
+ textAntiAlias = QtGui.QPainter.RenderHint.TextAntialiasing
705
+
706
+ self._scene.setItemIndexMethod(noIndex)
707
+ self._view.setViewportUpdateMode(minimalUpdate)
708
+ self._view.setAttribute(hoverTracking, True)
709
+ self._view.setAttribute(mouseTracking, True)
710
+ self._view.setHorizontalScrollBarPolicy(scrollOff)
711
+ self._view.setVerticalScrollBarPolicy(scrollOff)
712
+ self._view.setRenderHint(antiAlias, True)
713
+ self._view.setRenderHint(smoothPixmap, True)
714
+ self._view.setRenderHint(textAntiAlias, True)
715
+
716
+ r, g, b, a = color
717
+ qColor = QtGui.QColor(r, g, b, a)
718
+ brush = QtGui.QBrush(qColor)
719
+ self._scene.setBackgroundBrush(brush)
720
+ self._view.setBackgroundBrush(brush)
721
+
722
+ # Persistent draw layer — a transparent pixmap that one-time draw calls paint onto.
723
+ # Sits at z = -1e9, permanently below all instantiated objects (which start at z=1.0
724
+ # and decrease). Never added to _itemList, so it is invisible to hit-testing,
725
+ # removeAll, and z-order management.
726
+ self._drawPixmap = QtGui.QPixmap(width, height)
727
+ self._drawPixmap.fill(QtCore.Qt.GlobalColor.transparent)
728
+ self._drawLayer = QtWidgets.QGraphicsPixmapItem(self._drawPixmap)
729
+ self._drawLayer.setZValue(-1e9)
730
+ self._scene.addItem(self._drawLayer)
731
+
732
+ self._commandHandlers = {
733
+ 'show': self._show,
734
+ 'hide': self._hide,
735
+ 'close': self._close,
736
+ 'add': self._add,
737
+ 'addOrder': self._addOrder,
738
+ 'remove': self._remove,
739
+ 'removeAll': self._removeAll,
740
+ 'move': self._move,
741
+ 'getOrder': self._getOrder,
742
+ 'setOrder': self._setOrder,
743
+ 'getColor': self._getColor,
744
+ 'setColor': self._setColor,
745
+ 'getTitle': self._getTitle,
746
+ 'setTitle': self._setTitle,
747
+ 'getWidth': self._getWidth,
748
+ 'getHeight': self._getHeight,
749
+ 'getSize': self._getSize,
750
+ 'setSize': self._setSize,
751
+ 'getPosition': self._getPosition,
752
+ 'setPosition': self._setPosition,
753
+ 'setToolTipText': self._setToolTipText,
754
+ 'showMouseCoordinates': self._showMouseCoordinates,
755
+ 'hideMouseCoordinates': self._hideMouseCoordinates,
756
+ 'addMenu': self._addMenu,
757
+ 'addPopupMenu': self._addPopupMenu,
758
+ 'write': self._write,
759
+ 'draw': self._draw,
760
+ 'clearDrawing': self._clearDrawing,
761
+ }
762
+
763
+ self._drawHandlers = {
764
+ 'rectangle': self._drawRectangle,
765
+ 'oval': self._drawOval,
766
+ 'circle': self._drawOval,
767
+ 'point': self._drawOval,
768
+ 'arc': self._drawArc,
769
+ 'line': self._drawLine,
770
+ 'polyline': self._drawPolyline,
771
+ 'polygon': self._drawPolygon,
772
+ 'icon': self._drawIcon,
773
+ 'label': self._drawLabel,
774
+ }
775
+
776
+ self._popupMenu = None
777
+ self._window.customContextMenuRequested.connect(self._onContextMenuRequested)
778
+
779
+ self._eventDispatcher = _GuiEventDispatcher(self)
780
+
781
+ def handleCommand(self, action, args, responseId):
782
+ handler = self._commandHandlers.get(action)
783
+ if handler is not None:
784
+ handler(args, responseId)
785
+
786
+ # ── Window visibility ──────────────────────────────────────────────────────
787
+
788
+ def _show(self, args, responseId):
789
+ self._window.show()
790
+
791
+ def _hide(self, args, responseId):
792
+ self._window.hide()
793
+
794
+ def _close(self, args, responseId):
795
+ self._window.close()
796
+
797
+ # ── Item management ────────────────────────────────────────────────────────
798
+
799
+ def _add(self, args, responseId):
800
+ """Adds an item at the top of the z-order (order = 0)."""
801
+ itemId = args.get('itemId')
802
+ x = args.get('x')
803
+ y = args.get('y')
804
+ addOrderArgs = {'itemId': itemId, 'order': 0, 'x': x, 'y': y}
805
+ self._addOrder(addOrderArgs, responseId=None)
806
+
807
+ def _addOrder(self, args, responseId):
808
+ """Adds an item at the specified z-order position."""
809
+ itemId = args.get('itemId')
810
+ order = args.get('order', 0)
811
+ x = args.get('x')
812
+ y = args.get('y')
813
+
814
+ item = self._guiRenderer._objectRegistry.get(itemId)
815
+ if item is None:
816
+ return
817
+
818
+ if item in self._itemList:
819
+ self._itemList.remove(item)
820
+ self._scene.removeItem(item._qObject)
821
+
822
+ order = max(0, min(len(self._itemList), order))
823
+ self._itemList.insert(order, item)
824
+
825
+ if order == 0:
826
+ qZValue = 1.0
827
+ if len(self._itemList) > 1:
828
+ neighbor = self._itemList[1]
829
+ qZValue = neighbor._qZValue + 1.0
830
+
831
+ elif order >= len(self._itemList) - 1:
832
+ qZValue = 0.0
833
+ if len(self._itemList) > 1:
834
+ neighbor = self._itemList[-2]
835
+ qZValue = neighbor._qZValue - 1.0
836
+
837
+ else:
838
+ frontNeighbor = self._itemList[order - 1]
839
+ backNeighbor = self._itemList[order + 1]
840
+ qZValue = (frontNeighbor._qZValue + backNeighbor._qZValue) / 2.0
841
+
842
+ item._qZValue = qZValue
843
+ if item._isControl:
844
+ item._qObject.setParent(self._window) # attach QWidget to window
845
+ item._qObject.show()
846
+ else:
847
+ item._qObject.setZValue(qZValue)
848
+ self._scene.addItem(item._qObject) # attach QGraphicsItem to scene
849
+ cacheMode = QtWidgets.QGraphicsItem.CacheMode.DeviceCoordinateCache
850
+ item._qObject.setCacheMode(cacheMode)
851
+
852
+ posArgs = {}
853
+ if x is not None:
854
+ posArgs['x'] = x
855
+ if y is not None:
856
+ posArgs['y'] = y
857
+ if posArgs:
858
+ item._setPosition(posArgs, responseId=None)
859
+
860
+ def _remove(self, args, responseId):
861
+ itemId = args.get('itemId')
862
+ item = self._guiRenderer._objectRegistry.get(itemId)
863
+ if item is not None and item in self._itemList:
864
+ self._itemList.remove(item)
865
+ if item._isControl:
866
+ item._qObject.setParent(None) # detach QWidget from window
867
+ item._qObject.hide()
868
+ else:
869
+ self._scene.removeItem(item._qObject)
870
+
871
+ def _removeAll(self, args, responseId):
872
+ self._view.setUpdatesEnabled(False)
873
+ for item in list(self._itemList):
874
+ if item._isControl:
875
+ item._qObject.setParent(None)
876
+ item._qObject.hide()
877
+ else:
878
+ self._scene.removeItem(item._qObject)
879
+ self._itemList.clear()
880
+ self._view.setUpdatesEnabled(True)
881
+ self._view.viewport().update()
882
+
883
+ def _move(self, args, responseId):
884
+ itemId = args.get('itemId')
885
+ x = args.get('x')
886
+ y = args.get('y')
887
+ item = self._guiRenderer._objectRegistry.get(itemId)
888
+ if item is not None and item in self._itemList:
889
+ item._setPosition({'x': x, 'y': y}, responseId=None)
890
+
891
+ def _getOrder(self, args, responseId):
892
+ itemId = args.get('itemId')
893
+ order = None
894
+ for i, item in enumerate(self._itemList):
895
+ if item.objectId == itemId:
896
+ order = i
897
+ break
898
+ self._guiRenderer.sendResponse(responseId, [order])
899
+
900
+ def _setOrder(self, args, responseId):
901
+ itemId = args.get('itemId')
902
+ order = args.get('order', 0)
903
+ addOrderArgs = {'itemId': itemId, 'order': order, 'x': None, 'y': None}
904
+ self._addOrder(addOrderArgs, responseId=None)
905
+
906
+ # ── Color ──────────────────────────────────────────────────────────────────
907
+
908
+ def _getColor(self, args, responseId):
909
+ qColor = self._scene.backgroundBrush().color()
910
+ r = qColor.red()
911
+ g = qColor.green()
912
+ b = qColor.blue()
913
+ a = qColor.alpha()
914
+ self._guiRenderer.sendResponse(responseId, [r, g, b, a])
915
+
916
+ def _setColor(self, args, responseId):
917
+ color = args.get('color', [255, 255, 255, 255])
918
+ r, g, b, a = color
919
+ qColor = QtGui.QColor(r, g, b, a)
920
+ brush = QtGui.QBrush(qColor)
921
+ self._scene.setBackgroundBrush(brush)
922
+ self._view.setBackgroundBrush(brush)
923
+
924
+ # ── Title ──────────────────────────────────────────────────────────────────
925
+
926
+ def _getTitle(self, args, responseId):
927
+ title = self._window.windowTitle()
928
+ self._guiRenderer.sendResponse(responseId, [title])
929
+
930
+ def _setTitle(self, args, responseId):
931
+ title = args.get('title', '')
932
+ self._window.setWindowTitle(title)
933
+
934
+ # ── Size ───────────────────────────────────────────────────────────────────
935
+
936
+ def _getWidth(self, args, responseId):
937
+ self._guiRenderer.sendResponse(responseId, [int(self._scene.width())])
938
+
939
+ def _getHeight(self, args, responseId):
940
+ self._guiRenderer.sendResponse(responseId, [int(self._scene.height())])
941
+
942
+ def _getSize(self, args, responseId):
943
+ width = int(self._scene.width())
944
+ height = int(self._scene.height())
945
+ self._guiRenderer.sendResponse(responseId, [width, height])
946
+
947
+ def _setSize(self, args, responseId):
948
+ width = args.get('width', 600)
949
+ height = args.get('height', 400)
950
+ if width > 0 and height > 0:
951
+ pos = self._window.pos()
952
+ self._scene.setSceneRect(0, 0, width, height)
953
+ self._window.setFixedSize(width, height)
954
+ self._window.move(pos)
955
+
956
+ # Recreate the draw layer at the new dimensions. The old drawing is cleared
957
+ # because scaling painted geometry would distort it.
958
+ self._drawPixmap = QtGui.QPixmap(width, height)
959
+ self._drawPixmap.fill(QtCore.Qt.GlobalColor.transparent)
960
+ self._drawLayer.setPixmap(self._drawPixmap)
961
+
962
+ # ── Position ───────────────────────────────────────────────────────────────
963
+
964
+ def _getPosition(self, args, responseId):
965
+ x = int(self._window.x())
966
+ y = int(self._window.y())
967
+ self._guiRenderer.sendResponse(responseId, [x, y])
968
+
969
+ def _setPosition(self, args, responseId):
970
+ x = int(args.get('x', 0))
971
+ y = int(args.get('y', 0))
972
+ width = int(self._scene.width())
973
+ height = int(self._scene.height())
974
+ self._window.setGeometry(x, y, width, height)
975
+
976
+ # ── Tooltip ────────────────────────────────────────────────────────────────
977
+
978
+ def _setToolTipText(self, args, responseId):
979
+ text = args.get('text')
980
+ self._toolTipText = text
981
+ self._view.setToolTip(text)
982
+
983
+ def _showMouseCoordinates(self, args, responseId):
984
+ self._showCoords = True
985
+ self._view.setToolTip(None)
986
+
987
+ def _hideMouseCoordinates(self, args, responseId):
988
+ self._showCoords = False
989
+ self._view.setToolTip(self._toolTipText)
990
+
991
+ # ── Menu ───────────────────────────────────────────────────────────────────
992
+
993
+ def _addMenu(self, args, responseId):
994
+ menuId = args.get('menuId')
995
+ menu = self._guiRenderer._objectRegistry.get(menuId)
996
+ if menu is not None:
997
+ self._window.menuBar().addMenu(menu._qMenu)
998
+
999
+ def _addPopupMenu(self, args, responseId):
1000
+ menuId = args.get('menuId')
1001
+ menu = self._guiRenderer._objectRegistry.get(menuId)
1002
+ if menu is not None:
1003
+ self._popupMenu = menu._qMenu
1004
+
1005
+ def _onContextMenuRequested(self, pos):
1006
+ if self._popupMenu is not None:
1007
+ globalPos = self._window.mapToGlobal(pos)
1008
+ self._popupMenu.exec(globalPos)
1009
+
1010
+ # ── Draw layer ─────────────────────────────────────────────────────────────
1011
+
1012
+ def _draw(self, args, responseId):
1013
+ """Dispatches a one-time draw command to the appropriate shape painter."""
1014
+ shape = args.get('shape')
1015
+ handler = self._drawHandlers.get(shape)
1016
+ if handler is not None:
1017
+ handler(args)
1018
+
1019
+ def _clearDrawing(self, args, responseId):
1020
+ """Clears all one-time drawn content from the draw layer."""
1021
+ self._drawPixmap.fill(QtCore.Qt.GlobalColor.transparent)
1022
+ self._drawLayer.setPixmap(self._drawPixmap)
1023
+
1024
+ def _openDrawPainter(self):
1025
+ """Opens an antialiased QPainter on the draw pixmap and returns it."""
1026
+ painter = QtGui.QPainter(self._drawPixmap)
1027
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
1028
+ painter.setRenderHint(QtGui.QPainter.RenderHint.SmoothPixmapTransform)
1029
+ return painter
1030
+
1031
+ def _closeDrawPainter(self, painter):
1032
+ """Ends the painter and pushes the updated pixmap to the draw layer."""
1033
+ painter.end()
1034
+ self._drawLayer.setPixmap(self._drawPixmap)
1035
+
1036
+ def _makeDrawPen(self, color, thickness):
1037
+ """Returns a QPen for the given [r,g,b,a] color and line thickness."""
1038
+ r, g, b, a = color
1039
+ pen = QtGui.QPen(QtGui.QColor(r, g, b, a))
1040
+ pen.setWidth(thickness)
1041
+ return pen
1042
+
1043
+ def _makeDrawBrush(self, color, fill):
1044
+ """Returns a filled QBrush if fill is True, otherwise NoBrush."""
1045
+ if fill:
1046
+ r, g, b, a = color
1047
+ return QtGui.QBrush(QtGui.QColor(r, g, b, a))
1048
+ return QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush)
1049
+
1050
+ def _drawOval(self, args):
1051
+ """
1052
+ Paints an ellipse (or circle) onto the draw layer.
1053
+ Used by both 'oval' and 'circle' draw commands.
1054
+
1055
+ Args:
1056
+ x, y — top-left corner of the bounding box
1057
+ width, height — bounding box dimensions
1058
+ color — [r, g, b, a]
1059
+ fill — bool
1060
+ thickness — int (line width)
1061
+ rotation — degrees (CCW visual)
1062
+ """
1063
+ x = args.get('x', 0)
1064
+ y = args.get('y', 0)
1065
+ width = args.get('width', 10)
1066
+ height = args.get('height', 10)
1067
+ color = args.get('color', [0, 0, 0, 255])
1068
+ fill = args.get('fill', False)
1069
+ thickness = args.get('thickness', 1)
1070
+ rotation = args.get('rotation', 0)
1071
+
1072
+ painter = self._openDrawPainter()
1073
+ painter.setPen(self._makeDrawPen(color, thickness))
1074
+ painter.setBrush(self._makeDrawBrush(color, fill))
1075
+
1076
+ if rotation != 0:
1077
+ cx = x + width / 2
1078
+ cy = y + height / 2
1079
+ painter.translate(cx, cy)
1080
+ painter.rotate(-rotation) # Qt rotates CW; CP rotation is CCW visual
1081
+ painter.drawEllipse(QtCore.QRectF(-width / 2, -height / 2, width, height))
1082
+ else:
1083
+ painter.drawEllipse(QtCore.QRectF(x, y, width, height))
1084
+
1085
+ self._closeDrawPainter(painter)
1086
+
1087
+ def _drawRectangle(self, args):
1088
+ """
1089
+ Paints a rectangle onto the draw layer.
1090
+
1091
+ Args:
1092
+ x, y — top-left corner of the bounding box
1093
+ width, height — bounding box dimensions
1094
+ color — [r, g, b, a]
1095
+ fill — bool
1096
+ thickness — int (line width)
1097
+ rotation — degrees (CCW visual)
1098
+ """
1099
+ x = args.get('x', 0)
1100
+ y = args.get('y', 0)
1101
+ width = args.get('width', 10)
1102
+ height = args.get('height', 10)
1103
+ color = args.get('color', [0, 0, 0, 255])
1104
+ fill = args.get('fill', False)
1105
+ thickness = args.get('thickness', 1)
1106
+ rotation = args.get('rotation', 0)
1107
+
1108
+ painter = self._openDrawPainter()
1109
+ painter.setPen(self._makeDrawPen(color, thickness))
1110
+ painter.setBrush(self._makeDrawBrush(color, fill))
1111
+
1112
+ if rotation != 0:
1113
+ cx = x + width / 2
1114
+ cy = y + height / 2
1115
+ painter.translate(cx, cy)
1116
+ painter.rotate(-rotation)
1117
+ painter.drawRect(QtCore.QRectF(-width / 2, -height / 2, width, height))
1118
+ else:
1119
+ painter.drawRect(QtCore.QRectF(x, y, width, height))
1120
+
1121
+ self._closeDrawPainter(painter)
1122
+
1123
+ def _drawArc(self, args):
1124
+ """
1125
+ Paints an arc (open, pie, or chord) onto the draw layer.
1126
+ Uses the same QPainterPath construction as ArcMirror.
1127
+
1128
+ Args:
1129
+ x, y — top-left corner of the bounding box
1130
+ width, height — bounding box dimensions
1131
+ startAngle — degrees
1132
+ endAngle — degrees
1133
+ style — 0 (PIE), 1 (OPEN), 2 (CHORD)
1134
+ color — [r, g, b, a]
1135
+ fill — bool
1136
+ thickness — int (line width)
1137
+ rotation — degrees (CCW visual)
1138
+ """
1139
+ x = args.get('x', 0)
1140
+ y = args.get('y', 0)
1141
+ width = args.get('width', 100)
1142
+ height = args.get('height', 100)
1143
+ startAngle = args.get('startAngle', 180)
1144
+ endAngle = args.get('endAngle', 360)
1145
+ style = args.get('style', _OPEN)
1146
+ color = args.get('color', [0, 0, 0, 255])
1147
+ fill = args.get('fill', False)
1148
+ thickness = args.get('thickness', 1)
1149
+ rotation = args.get('rotation', 0)
1150
+
1151
+ arcWidth = -(endAngle - startAngle) # Qt sweeps CW for positive span
1152
+
1153
+ path = QtGui.QPainterPath()
1154
+ path.arcMoveTo(0, 0, width, height, startAngle)
1155
+ path.arcTo(0, 0, width, height, startAngle, arcWidth)
1156
+ if style == _PIE:
1157
+ path.lineTo(width / 2, height / 2)
1158
+ path.closeSubpath()
1159
+ elif style == _CHORD:
1160
+ path.closeSubpath()
1161
+
1162
+ painter = self._openDrawPainter()
1163
+ painter.setPen(self._makeDrawPen(color, thickness))
1164
+ painter.setBrush(self._makeDrawBrush(color, fill))
1165
+
1166
+ if rotation != 0:
1167
+ cx = x + width / 2
1168
+ cy = y + height / 2
1169
+ painter.translate(cx, cy)
1170
+ painter.rotate(-rotation)
1171
+ painter.translate(-width / 2, -height / 2)
1172
+ else:
1173
+ painter.translate(x, y)
1174
+
1175
+ painter.drawPath(path)
1176
+ self._closeDrawPainter(painter)
1177
+
1178
+ def _drawLine(self, args):
1179
+ """
1180
+ Paints a line segment onto the draw layer.
1181
+ Rotation is applied around the line's midpoint.
1182
+
1183
+ Args:
1184
+ x1, y1, x2, y2 — endpoint coordinates (scene space)
1185
+ color — [r, g, b, a]
1186
+ thickness — int (line width)
1187
+ rotation — degrees (CCW visual)
1188
+ """
1189
+ x1 = args.get('x1', 0)
1190
+ y1 = args.get('y1', 0)
1191
+ x2 = args.get('x2', 100)
1192
+ y2 = args.get('y2', 100)
1193
+ color = args.get('color', [0, 0, 0, 255])
1194
+ thickness = args.get('thickness', 1)
1195
+ rotation = args.get('rotation', 0)
1196
+
1197
+ painter = self._openDrawPainter()
1198
+ painter.setPen(self._makeDrawPen(color, thickness))
1199
+
1200
+ if rotation != 0:
1201
+ mx = (x1 + x2) / 2
1202
+ my = (y1 + y2) / 2
1203
+ painter.translate(mx, my)
1204
+ painter.rotate(-rotation)
1205
+ painter.translate(-mx, -my)
1206
+
1207
+ painter.drawLine(QtCore.QPointF(x1, y1), QtCore.QPointF(x2, y2))
1208
+ self._closeDrawPainter(painter)
1209
+
1210
+ def _drawPolyline(self, args):
1211
+ """
1212
+ Paints an open polyline onto the draw layer.
1213
+ Rotation is applied around the bounding box center.
1214
+
1215
+ Args:
1216
+ xPoints — list of x coordinates (scene space)
1217
+ yPoints — list of y coordinates (scene space)
1218
+ color — [r, g, b, a]
1219
+ thickness — int (line width)
1220
+ rotation — degrees (CCW visual)
1221
+ """
1222
+ xPoints = args.get('xPoints', [0, 100])
1223
+ yPoints = args.get('yPoints', [0, 100])
1224
+ color = args.get('color', [0, 0, 0, 255])
1225
+ thickness = args.get('thickness', 1)
1226
+ rotation = args.get('rotation', 0)
1227
+
1228
+ points = [QtCore.QPointF(x, y) for x, y in zip(xPoints, yPoints)]
1229
+ polygon = QtGui.QPolygonF(points)
1230
+
1231
+ painter = self._openDrawPainter()
1232
+ painter.setPen(self._makeDrawPen(color, thickness))
1233
+ painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
1234
+
1235
+ if rotation != 0:
1236
+ cx = (min(xPoints) + max(xPoints)) / 2
1237
+ cy = (min(yPoints) + max(yPoints)) / 2
1238
+ painter.translate(cx, cy)
1239
+ painter.rotate(-rotation)
1240
+ painter.translate(-cx, -cy)
1241
+
1242
+ painter.drawPolyline(polygon)
1243
+ self._closeDrawPainter(painter)
1244
+
1245
+ def _drawPolygon(self, args):
1246
+ """
1247
+ Paints a closed polygon onto the draw layer.
1248
+ Rotation is applied around the bounding box center.
1249
+
1250
+ Args:
1251
+ xPoints — list of x coordinates (scene space)
1252
+ yPoints — list of y coordinates (scene space)
1253
+ color — [r, g, b, a]
1254
+ fill — bool
1255
+ thickness — int (line width)
1256
+ rotation — degrees (CCW visual)
1257
+ """
1258
+ xPoints = args.get('xPoints', [0, 100, 50])
1259
+ yPoints = args.get('yPoints', [0, 0, 100])
1260
+ color = args.get('color', [0, 0, 0, 255])
1261
+ fill = args.get('fill', False)
1262
+ thickness = args.get('thickness', 1)
1263
+ rotation = args.get('rotation', 0)
1264
+
1265
+ points = [QtCore.QPointF(x, y) for x, y in zip(xPoints, yPoints)]
1266
+ polygon = QtGui.QPolygonF(points)
1267
+
1268
+ painter = self._openDrawPainter()
1269
+ painter.setPen(self._makeDrawPen(color, thickness))
1270
+ painter.setBrush(self._makeDrawBrush(color, fill))
1271
+
1272
+ if rotation != 0:
1273
+ cx = (min(xPoints) + max(xPoints)) / 2
1274
+ cy = (min(yPoints) + max(yPoints)) / 2
1275
+ painter.translate(cx, cy)
1276
+ painter.rotate(-rotation)
1277
+ painter.translate(-cx, -cy)
1278
+
1279
+ painter.drawPolygon(polygon)
1280
+ self._closeDrawPainter(painter)
1281
+
1282
+ def _drawIcon(self, args):
1283
+ """
1284
+ Paints an image file onto the draw layer.
1285
+
1286
+ Args:
1287
+ filename — path to the image file
1288
+ x, y — top-left position in scene space
1289
+ width, height — target dimensions (None = use pixmap's native size)
1290
+ rotation — degrees (CCW visual)
1291
+ """
1292
+ filename = args.get('filename', '')
1293
+ x = args.get('x', 0)
1294
+ y = args.get('y', 0)
1295
+ width = args.get('width')
1296
+ height = args.get('height')
1297
+ rotation = args.get('rotation', 0)
1298
+
1299
+ pixmap = QtGui.QPixmap(filename)
1300
+ if pixmap.isNull():
1301
+ return
1302
+
1303
+ if width is None and height is None:
1304
+ width = pixmap.width()
1305
+ height = pixmap.height()
1306
+ elif width is None:
1307
+ width = int(pixmap.width() * (height / pixmap.height()))
1308
+ elif height is None:
1309
+ height = int(pixmap.height() * (width / pixmap.width()))
1310
+
1311
+ pixmap = pixmap.scaled(int(width), int(height),
1312
+ QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
1313
+ QtCore.Qt.TransformationMode.SmoothTransformation)
1314
+
1315
+ painter = self._openDrawPainter()
1316
+
1317
+ if rotation != 0:
1318
+ cx = x + width / 2
1319
+ cy = y + height / 2
1320
+ painter.translate(cx, cy)
1321
+ painter.rotate(-rotation)
1322
+ painter.drawPixmap(QtCore.QPointF(-width / 2, -height / 2), pixmap)
1323
+ else:
1324
+ painter.drawPixmap(QtCore.QPointF(x, y), pixmap)
1325
+
1326
+ self._closeDrawPainter(painter)
1327
+
1328
+ def _drawLabel(self, args):
1329
+ """
1330
+ Paints a text string onto the draw layer.
1331
+ Text is positioned with its top-left at (x, y), matching Label's placement.
1332
+
1333
+ Args:
1334
+ text — string
1335
+ x, y — top-left of the text in scene space
1336
+ color — [r, g, b, a]
1337
+ font — None, or [name, [weight, italic], size]
1338
+ """
1339
+ text = str(args.get('text', ''))
1340
+ x = args.get('x', 0)
1341
+ y = args.get('y', 0)
1342
+ color = args.get('color', [0, 0, 0, 255])
1343
+ font = args.get('font')
1344
+
1345
+ painter = self._openDrawPainter()
1346
+
1347
+ r, g, b, a = color
1348
+ painter.setPen(QtGui.QColor(r, g, b, a))
1349
+
1350
+ if font is not None:
1351
+ name, style, size = font
1352
+ weight, italic = style
1353
+ qFont = QtGui.QFont(name, size)
1354
+ qFont.setWeight(QtGui.QFont.Weight(weight))
1355
+ qFont.setItalic(italic)
1356
+ painter.setFont(qFont)
1357
+
1358
+ # QRectF positions top-left at (x, y), matching Label's setPos behavior
1359
+ painter.drawText(
1360
+ QtCore.QRectF(x, y, self._drawPixmap.width(), self._drawPixmap.height()),
1361
+ QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.TextFlag.TextSingleLine,
1362
+ text)
1363
+
1364
+ self._closeDrawPainter(painter)
1365
+
1366
+ # ── Write ──────────────────────────────────────────────────────────────────
1367
+
1368
+ def _write(self, args, responseId):
1369
+ """
1370
+ Renders the scene to a QPixmap and saves it to a file.
1371
+ Optional width/height args resize the output; omitting one preserves aspect ratio.
1372
+ Sends [success (bool), resolvedPath (str)] back to the parent process.
1373
+ """
1374
+ import os
1375
+ filename = args.get('filename', 'display.png')
1376
+ width = args.get('width')
1377
+ height = args.get('height')
1378
+
1379
+ sceneRect = self._scene.sceneRect()
1380
+ pixmap = QtGui.QPixmap(int(sceneRect.width()), int(sceneRect.height()))
1381
+ pixmap.fill(QtCore.Qt.GlobalColor.transparent)
1382
+ painter = QtGui.QPainter(pixmap)
1383
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
1384
+ painter.setRenderHint(QtGui.QPainter.RenderHint.SmoothPixmapTransform)
1385
+ self._scene.render(painter)
1386
+ painter.end()
1387
+
1388
+ if width is not None or height is not None:
1389
+ origW = pixmap.width()
1390
+ origH = pixmap.height()
1391
+ width = int(origW * (height / origH)) if width is None else int(width)
1392
+ height = int(origH * (width / origW)) if height is None else int(height)
1393
+ pixmap = pixmap.scaled(width, height,
1394
+ QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
1395
+ QtCore.Qt.TransformationMode.SmoothTransformation)
1396
+
1397
+ success = pixmap.save(filename)
1398
+ resolvedPath = os.path.abspath(filename)
1399
+ self._guiRenderer.sendResponse(responseId, [success, resolvedPath])
1400
+
1401
+ # ── Close event ────────────────────────────────────────────────────────────
1402
+
1403
+ def onWindowClose(self):
1404
+ isRegistered = (self.objectId, 'displayClose') in self._guiRenderer._registeredEvents
1405
+ if isRegistered:
1406
+ self._guiRenderer.sendEvent('displayClose', self.objectId, {})
1407
+
1408
+
1409
+ #######################################################################################
1410
+ # _DrawableMirror — base mirror for all drawable items
1411
+ #######################################################################################
1412
+
1413
+ class _DrawableMirror:
1414
+ """
1415
+ Base class for all mirror objects that can be placed on a Display.
1416
+ Provides position, size, rotation, hit-testing, visibility, and tooltip command handlers.
1417
+
1418
+ Concrete classes (e.g. RectangleMirror) must:
1419
+ 1. Call super().__init__(objectId, guiRenderer) first.
1420
+ 2. Create self._qObject (the underlying QGraphicsItem).
1421
+ 3. Set self._localCornerX, _localCornerY, _width, _height from their constructor args.
1422
+ 4. Call self._qObject.setPos(x, y) to place the item.
1423
+ 5. Override _setSize if their QGraphicsItem needs shape-specific resizing.
1424
+
1425
+ Because Python resolves 'self.method' through the MRO at the time the
1426
+ _commandHandlers dict is built, any override of _setSize in a subclass is
1427
+ automatically used without needing to re-register the handler.
1428
+ """
1429
+
1430
+ _isControl = False # Controls override this to True
1431
+
1432
+ def __init__(self, objectId, guiRenderer):
1433
+ self.objectId = objectId
1434
+ self._guiRenderer = guiRenderer
1435
+ self._qObject = None # set by concrete class constructor
1436
+ self._qZValue = 0.0 # assigned by DisplayMirror._addOrder
1437
+
1438
+ # cached object values, used as fallbacks
1439
+ self._localCornerX = 0
1440
+ self._localCornerY = 0
1441
+ self._width = 0
1442
+ self._height = 0
1443
+ self._rotation = 0
1444
+ self._toolTipText = None
1445
+
1446
+ # GuiRenderer only handles commands that alter an object visually, or
1447
+ # that rely on hit testing. The true state is contained in the real gui.py object.
1448
+ self._commandHandlers = {
1449
+ 'setPosition': self.setPosition,
1450
+ 'getSize': self.getSize,
1451
+ 'setSize': self.setSize,
1452
+ 'setRotation': self.setRotation,
1453
+ 'contains': self.contains,
1454
+ 'intersects': self.intersects,
1455
+ 'encloses': self.encloses,
1456
+ 'setToolTipText': self.setToolTipText,
1457
+ 'show': self.show,
1458
+ 'hide': self.hide,
1459
+ }
1460
+
1461
+ def handleCommand(self, action, args, responseId):
1462
+ """
1463
+ Dispatches an incoming command to the appropriate handler method.
1464
+ Unknown actions are silently ignored.
1465
+ """
1466
+ handler = self._commandHandlers.get(action)
1467
+ if handler is not None:
1468
+ handler(args, responseId)
1469
+
1470
+ # ── Position ───────────────────────────────────────────────────────────────
1471
+
1472
+ def setPosition(self, args, responseId):
1473
+ """
1474
+ Set the item's local (x, y) coordinates.
1475
+ """
1476
+ x = args.get('x', self._localCornerX)
1477
+ y = args.get('y', self._localCornerY)
1478
+
1479
+ # update Qt item
1480
+ self._qObject.setPos(x, y)
1481
+
1482
+ # store cached position
1483
+ self._localCornerX = x
1484
+ self._localCornerY = y
1485
+
1486
+ # ── Size ───────────────────────────────────────────────────────────────────
1487
+
1488
+ def getSize(self, args, responseId):
1489
+ """
1490
+ Returns [width, height] for this item. Used by Icon and Label to
1491
+ resolve dimensions after creation, since pixel data lives in Qt.
1492
+ """
1493
+ self._guiRenderer.sendResponse(responseId, [self._width, self._height])
1494
+
1495
+ def setSize(self, args, responseId):
1496
+ """
1497
+ Base size handler — updates _width/_height only.
1498
+ Concrete classes override this to also update their QGraphicsItem geometry.
1499
+ """
1500
+ self._width = args.get('width', self._width)
1501
+ self._height = args.get('height', self._height)
1502
+
1503
+ # ── Rotation ───────────────────────────────────────────────────────────────
1504
+
1505
+ def setRotation(self, args, responseId):
1506
+ """
1507
+ Applies rotation to the Qt object.
1508
+ We always rotate around the center of the shape.
1509
+ """
1510
+ rotation = args.get('rotation', self._rotation)
1511
+ centerX = (self._width / 2)
1512
+ centerY = (self._height / 2)
1513
+
1514
+ # update internal rotation
1515
+ self._rotation = rotation
1516
+
1517
+ # update Qt scene
1518
+ qtDegree = -rotation % 360 # CP increases CCW; Qt increases CW
1519
+ self._qObject.setTransformOriginPoint(centerX, centerY)
1520
+ self._qObject.prepareGeometryChange()
1521
+ self._qObject.setRotation(qtDegree)
1522
+
1523
+ # ── Hit testing ────────────────────────────────────────────────────────────
1524
+
1525
+ def contains(self, args, responseId):
1526
+ """Returns True if the point (x, y) is inside this item."""
1527
+ x = args.get('x', 0)
1528
+ y = args.get('y', 0)
1529
+ globalPoint = QtCore.QPointF(x, y)
1530
+ localPoint = self._qObject.mapFromScene(globalPoint)
1531
+ result = self._qObject.contains(localPoint)
1532
+ self._guiRenderer.sendResponse(responseId, [result])
1533
+
1534
+ def intersects(self, args, responseId):
1535
+ """Returns True if this item intersects another item."""
1536
+ otherObjectId = args.get('otherObjectId')
1537
+ otherMirror = self._guiRenderer._objectRegistry.get(otherObjectId)
1538
+ result = False
1539
+
1540
+ if otherMirror is not None:
1541
+ pathA = self._qObject.mapToScene(self._qObject.shape())
1542
+ pathB = otherMirror._qObject.mapToScene(otherMirror._qObject.shape())
1543
+ result = pathA.intersects(pathB)
1544
+
1545
+ self._guiRenderer.sendResponse(responseId, [result])
1546
+
1547
+ def encloses(self, args, responseId):
1548
+ """Returns True if this item fully encloses another item."""
1549
+ otherObjectId = args.get('otherObjectId')
1550
+ otherMirror = self._guiRenderer._objectRegistry.get(otherObjectId)
1551
+ result = False
1552
+
1553
+ if otherMirror is not None:
1554
+ pathA = self._qObject.mapToScene(self._qObject.shape())
1555
+ pathB = otherMirror._qObject.mapToScene(otherMirror._qObject.shape())
1556
+ result = pathA.contains(pathB)
1557
+
1558
+ self._guiRenderer.sendResponse(responseId, [result])
1559
+
1560
+ # ── Visibility ─────────────────────────────────────────────────────────────
1561
+
1562
+ def show(self, args, responseId):
1563
+ self._qObject.setVisible(True)
1564
+
1565
+ def hide(self, args, responseId):
1566
+ self._qObject.setVisible(False)
1567
+
1568
+ # ── Tooltip ────────────────────────────────────────────────────────────────
1569
+
1570
+ def setToolTipText(self, args, responseId):
1571
+ text = args.get('text')
1572
+ self._toolTipText = text
1573
+ self._qObject.setToolTip(text)
1574
+
1575
+
1576
+ #######################################################################################
1577
+ # _GraphicsMirror — base mirror for shape primitives
1578
+ #######################################################################################
1579
+
1580
+ class _GraphicsMirror(_DrawableMirror):
1581
+ """
1582
+ Extends _DrawableMirror with color, fill, and line-thickness support.
1583
+ All concrete shape classes (RectangleMirror, OvalMirror, etc.) extend this.
1584
+
1585
+ Constructor extracts 'color', 'fill', and 'thickness' from the
1586
+ args dict and stores them. The concrete class is responsible for creating
1587
+ _qObject and then calling _applyColor() and _applyThickness() to push the
1588
+ initial values to Qt.
1589
+ """
1590
+
1591
+ def __init__(self, objectId, args, guiRenderer):
1592
+ super().__init__(objectId, guiRenderer)
1593
+
1594
+ self._color = args.get('color', [0, 0, 0, 255])
1595
+ self._fill = args.get('fill', False)
1596
+ self._thickness = args.get('thickness', 1)
1597
+
1598
+ # add graphics-specific command handlers on top of drawable ones
1599
+ self._commandHandlers.update({
1600
+ 'setColor': self.setColor,
1601
+ 'setFill': self.setFill,
1602
+ 'setThickness': self.setThickness,
1603
+ })
1604
+
1605
+ def _applyColor(self):
1606
+ """
1607
+ Pushes self._color to the QPen outline and, if filled, to the QBrush fill.
1608
+ Must be called after self._qObject is created.
1609
+ """
1610
+ r, g, b, a = self._color
1611
+ qColor = QtGui.QColor(r, g, b, a)
1612
+
1613
+ qPen = self._qObject.pen()
1614
+ qPen.setColor(qColor)
1615
+ self._qObject.setPen(qPen)
1616
+
1617
+ if self._fill:
1618
+ qBrush = QtGui.QBrush(qColor)
1619
+ else:
1620
+ qBrush = QtGui.QBrush(QtGui.QColor(0, 0, 0, 0)) # fully transparent
1621
+ self._qObject.setBrush(qBrush)
1622
+
1623
+ def _applyThickness(self):
1624
+ """
1625
+ Pushes self._thickness to the QPen line width.
1626
+ Must be called after self._qObject is created.
1627
+ """
1628
+ qPen = self._qObject.pen()
1629
+ qPen.setWidth(self._thickness)
1630
+ self._qObject.setPen(qPen)
1631
+
1632
+ # ── Color ──────────────────────────────────────────────────────────────────
1633
+
1634
+ def setColor(self, args, responseId):
1635
+ self._color = args.get('color', [0, 0, 0, 255])
1636
+ self._applyColor()
1637
+
1638
+ # ── Fill ───────────────────────────────────────────────────────────────────
1639
+
1640
+ def setFill(self, args, responseId):
1641
+ self._fill = bool(args.get('fill', False))
1642
+ self._applyColor() # brush depends on fill state, so re-apply color
1643
+
1644
+ # ── Thickness ──────────────────────────────────────────────────────────────
1645
+
1646
+ def setThickness(self, args, responseId):
1647
+ self._thickness = int(args.get('thickness', 1))
1648
+ self._applyThickness()
1649
+
1650
+
1651
+ #######################################################################################
1652
+ # RectangleMirror — mirror of gui.py's Rectangle
1653
+ #######################################################################################
1654
+
1655
+ class RectangleMirror(_GraphicsMirror):
1656
+ """
1657
+ Mirror of gui.py's Rectangle. Backed by a QGraphicsRectItem.
1658
+
1659
+ Constructor args expected in the 'args' dict:
1660
+ x, y — pre-rotation top-left corner (pre-processed by gui.py)
1661
+ width, height — dimensions
1662
+ color — [r, g, b, a]
1663
+ fill — bool
1664
+ thickness — int (line width)
1665
+ rotation — degrees (CCW visual, 0 = no rotation)
1666
+ """
1667
+
1668
+ def __init__(self, objectId, args, guiRenderer):
1669
+ super().__init__(objectId, args, guiRenderer)
1670
+
1671
+ rotation = args.get('rotation', 0)
1672
+
1673
+ self._localCornerX = args.get('x', 0)
1674
+ self._localCornerY = args.get('y', 0)
1675
+ self._width = args.get('width', 100)
1676
+ self._height = args.get('height', 100)
1677
+
1678
+ # create the backing Qt item
1679
+ self._qObject = QtWidgets.QGraphicsRectItem(0, 0, self._width, self._height)
1680
+
1681
+ # push initial values to Qt
1682
+ self._qObject.setPos(self._localCornerX, self._localCornerY)
1683
+ self._applyColor()
1684
+ self._applyThickness()
1685
+
1686
+ if rotation != 0:
1687
+ self.setRotation({'rotation': rotation}, responseId=None)
1688
+
1689
+ def setSize(self, args, responseId):
1690
+ """
1691
+ Overrides _DrawableMirror.setSize to also update the QRectF geometry.
1692
+ Called automatically via MRO when 'setSize' commands arrive.
1693
+ """
1694
+ width = args.get('width', self._width)
1695
+ height = args.get('height', self._height)
1696
+ if width > 0 and height > 0:
1697
+ rect = QtCore.QRectF(0, 0, width, height)
1698
+ self._qObject.prepareGeometryChange() # invalidate Qt hit box
1699
+ self._qObject.setRect(rect)
1700
+ self._width = width
1701
+ self._height = height
1702
+
1703
+
1704
+ #######################################################################################
1705
+ # OvalMirror — mirror of gui.py's Oval
1706
+ #######################################################################################
1707
+
1708
+ class OvalMirror(_GraphicsMirror):
1709
+ """
1710
+ Mirror of gui.py's Oval. Backed by a QGraphicsEllipseItem.
1711
+ Also used by Circle and Point (their differences are resolved on the gui.py side).
1712
+
1713
+ Constructor args expected in the 'args' dict:
1714
+ x, y — pre-rotation top-left corner (pre-processed by gui.py)
1715
+ width, height — dimensions
1716
+ color — [r, g, b, a]
1717
+ fill — bool
1718
+ thickness — int (line width)
1719
+ rotation — degrees (CCW visual, 0 = no rotation)
1720
+ """
1721
+
1722
+ def __init__(self, objectId, args, guiRenderer):
1723
+ super().__init__(objectId, args, guiRenderer)
1724
+
1725
+ rotation = args.get('rotation', 0)
1726
+
1727
+ self._localCornerX = args.get('x', 0)
1728
+ self._localCornerY = args.get('y', 0)
1729
+ self._width = args.get('width', 100)
1730
+ self._height = args.get('height', 100)
1731
+
1732
+ # create the backing Qt item
1733
+ self._qObject = QtWidgets.QGraphicsEllipseItem(0, 0, self._width, self._height)
1734
+
1735
+ # push initial values to Qt
1736
+ self._qObject.setPos(self._localCornerX, self._localCornerY)
1737
+ self._applyColor()
1738
+ self._applyThickness()
1739
+
1740
+ if rotation != 0:
1741
+ self.setRotation({'rotation': rotation}, responseId=None)
1742
+
1743
+ def setSize(self, args, responseId):
1744
+ """
1745
+ Overrides _DrawableMirror.setSize to also update the QRectF geometry.
1746
+ """
1747
+ width = args.get('width', self._width)
1748
+ height = args.get('height', self._height)
1749
+ if width > 0 and height > 0:
1750
+ rect = QtCore.QRectF(0, 0, width, height)
1751
+ self._qObject.prepareGeometryChange() # invalidate Qt hit box
1752
+ self._qObject.setRect(rect)
1753
+ self._width = width
1754
+ self._height = height
1755
+
1756
+
1757
+ #######################################################################################
1758
+ # ArcMirror — mirror of gui.py's Arc
1759
+ #######################################################################################
1760
+
1761
+ class ArcMirror(_GraphicsMirror):
1762
+ """
1763
+ Mirror of gui.py's Arc. Backed by a QGraphicsPathItem.
1764
+ Also used by ArcCircle (its differences are resolved on the gui.py side).
1765
+
1766
+ Constructor args expected in the 'args' dict:
1767
+ x, y — pre-rotation top-left corner (pre-processed by gui.py)
1768
+ width, height — dimensions
1769
+ startAngle — degrees
1770
+ endAngle — degrees
1771
+ style — 0 (PIE), 1 (OPEN), or 2 (CHORD)
1772
+ color — [r, g, b, a]
1773
+ fill — bool
1774
+ thickness — int (line width)
1775
+ rotation — degrees (CCW visual, 0 = no rotation)
1776
+ """
1777
+
1778
+ def __init__(self, objectId, args, guiRenderer):
1779
+ super().__init__(objectId, args, guiRenderer)
1780
+
1781
+ rotation = args.get('rotation', 0)
1782
+
1783
+ self._localCornerX = args.get('x', 0)
1784
+ self._localCornerY = args.get('y', 0)
1785
+ self._width = args.get('width', 100)
1786
+ self._height = args.get('height', 100)
1787
+
1788
+ self._startAngle = args.get('startAngle', 180)
1789
+ self._endAngle = args.get('endAngle', 360)
1790
+ self._style = args.get('style', _OPEN)
1791
+ self._arcWidth = -(self._endAngle - self._startAngle) # Qt angles are opposite
1792
+
1793
+ self._commandHandlers.update({
1794
+ 'setArcWidth': self._setArcWidth,
1795
+ })
1796
+
1797
+ # build the arc path
1798
+ path = self._buildPath(self._width, self._height)
1799
+ self._qObject = QtWidgets.QGraphicsPathItem(path)
1800
+
1801
+ # push initial values to Qt
1802
+ self._qObject.setPos(self._localCornerX, self._localCornerY)
1803
+ self._applyColor()
1804
+ self._applyThickness()
1805
+
1806
+ if rotation != 0:
1807
+ self.setRotation({'rotation': rotation}, responseId=None)
1808
+
1809
+ def _buildPath(self, width, height):
1810
+ """
1811
+ Builds a QPainterPath for the arc at the given dimensions.
1812
+ """
1813
+ path = QtGui.QPainterPath()
1814
+ path.arcMoveTo(0, 0, width, height, self._startAngle)
1815
+ path.arcTo(0, 0, width, height, self._startAngle, self._arcWidth)
1816
+
1817
+ if self._style == _PIE:
1818
+ centerX = width / 2
1819
+ centerY = height / 2
1820
+ path.lineTo(centerX, centerY)
1821
+ path.closeSubpath()
1822
+ elif self._style == _CHORD:
1823
+ path.closeSubpath()
1824
+
1825
+ return path
1826
+
1827
+ def setSize(self, args, responseId):
1828
+ """
1829
+ Overrides _DrawableMirror.setSize.
1830
+ Prefers scaling via QTransform; falls back to rebuilding the path
1831
+ if either current dimension is 0.
1832
+ """
1833
+ width = args.get('width', self._width)
1834
+ height = args.get('height', self._height)
1835
+ if width > 0 and height > 0:
1836
+
1837
+ if self._width != 0 and self._height != 0:
1838
+ scaleX = width / self._width
1839
+ scaleY = height / self._height
1840
+ oldPath = self._qObject.path()
1841
+ oldRect = oldPath.boundingRect()
1842
+ transform = QtGui.QTransform()
1843
+ transform.translate(-oldRect.x(), -oldRect.y())
1844
+ transform.scale(scaleX, scaleY)
1845
+ transform.translate(oldRect.x(), oldRect.y())
1846
+ path = transform.map(oldPath)
1847
+ else:
1848
+ path = self._buildPath(width, height)
1849
+
1850
+ self._qObject.prepareGeometryChange()
1851
+ self._qObject.setPath(path)
1852
+ self._width = width
1853
+ self._height = height
1854
+
1855
+ def _setArcWidth(self, args, responseId):
1856
+ """
1857
+ Changes the arc's sweep angle and rebuilds the path.
1858
+ 'arcWidth' uses Qt's sign convention: negative values sweep clockwise.
1859
+ """
1860
+ self._arcWidth = args.get('arcWidth', self._arcWidth)
1861
+ path = self._buildPath(self._width, self._height)
1862
+ self._qObject.prepareGeometryChange()
1863
+ self._qObject.setPath(path)
1864
+
1865
+
1866
+ #######################################################################################
1867
+ # LineMirror — mirror of gui.py's Line
1868
+ #######################################################################################
1869
+
1870
+ class LineMirror(_GraphicsMirror):
1871
+ """
1872
+ Mirror of gui.py's Line. Backed by a QGraphicsLineItem.
1873
+
1874
+ Separated from PolylineMirror because QGraphicsLineItem is simpler and faster
1875
+ than QGraphicsPathItem for a two-point line. It also has a direct setLine() API
1876
+ which makes setLength support trivial.
1877
+
1878
+ QGraphicsLineItem has no brush, so _applyColor is overridden to skip setBrush().
1879
+
1880
+ Constructor args expected in the 'args' dict:
1881
+ x, y — pre-rotation top-left corner (pre-processed by gui.py)
1882
+ width, height — dimensions
1883
+ xPoints — [x1, x2] endpoint x-coords, relative to the corner (pre-processed by gui.py)
1884
+ yPoints — [y1, y2] endpoint y-coords, relative to the corner (pre-processed by gui.py)
1885
+ color — [r, g, b, a]
1886
+ thickness — int (line width)
1887
+ rotation — degrees (CCW visual, 0 = no rotation)
1888
+ """
1889
+
1890
+ def __init__(self, objectId, args, guiRenderer):
1891
+ super().__init__(objectId, args, guiRenderer)
1892
+
1893
+ rotation = args.get('rotation', 0)
1894
+
1895
+ self._localCornerX = args.get('x', 0)
1896
+ self._localCornerY = args.get('y', 0)
1897
+ self._width = args.get('width', 100)
1898
+ self._height = args.get('height', 100)
1899
+
1900
+ self._xPoints = args.get('xPoints', [0, self._width])
1901
+ self._yPoints = args.get('yPoints', [0, self._height])
1902
+
1903
+ # store original size (for rebuilding flattened geometry)
1904
+ self._originalWidth = self._width
1905
+ self._originalHeight = self._height
1906
+
1907
+ innerX1, innerX2 = self._xPoints
1908
+ innerY1, innerY2 = self._yPoints
1909
+
1910
+ # create the backing Qt item
1911
+ self._qObject = QtWidgets.QGraphicsLineItem(innerX1, innerY1, innerX2, innerY2)
1912
+
1913
+ # push initial values to Qt
1914
+ self._qObject.setPos(self._localCornerX, self._localCornerY)
1915
+ self._applyColor()
1916
+ self._applyThickness()
1917
+
1918
+ # add Line-specific command handler
1919
+ self._commandHandlers['setLine'] = self._setLine
1920
+
1921
+ if rotation != 0:
1922
+ self.setRotation({'rotation': rotation}, responseId=None)
1923
+
1924
+ def _applyColor(self):
1925
+ """
1926
+ Overrides _GraphicsMirror._applyColor.
1927
+ QGraphicsLineItem has no brush, so we only set the pen color.
1928
+ """
1929
+ r, g, b, a = self._color
1930
+ qColor = QtGui.QColor(r, g, b, a)
1931
+ qPen = self._qObject.pen()
1932
+ qPen.setColor(qColor)
1933
+ self._qObject.setPen(qPen)
1934
+
1935
+ def _setLine(self, args, responseId):
1936
+ """
1937
+ Sets the line's endpoints directly. Used by gui.py's setLength().
1938
+ Takes global coordinates x1, y1, x2, y2 — recalculates corner position
1939
+ and local endpoints.
1940
+ """
1941
+ x1 = args.get('x1', 0)
1942
+ y1 = args.get('y1', 0)
1943
+ x2 = args.get('x2', 0)
1944
+ y2 = args.get('y2', 0)
1945
+
1946
+ self._localCornerX = min(x1, x2)
1947
+ self._localCornerY = min(y1, y2)
1948
+ self._width = abs(x2 - x1)
1949
+ self._height = abs(y2 - y1)
1950
+
1951
+ innerX1 = x1 - self._localCornerX
1952
+ innerY1 = y1 - self._localCornerY
1953
+ innerX2 = x2 - self._localCornerX
1954
+ innerY2 = y2 - self._localCornerY
1955
+
1956
+ self._xPoints = [innerX1, innerX2]
1957
+ self._yPoints = [innerY1, innerY2]
1958
+
1959
+ self._qObject.prepareGeometryChange()
1960
+ self._qObject.setLine(innerX1, innerY1, innerX2, innerY2)
1961
+ self.setPosition({'x': self._localCornerX, 'y': self._localCornerY}, responseId=None)
1962
+
1963
+ def setSize(self, args, responseId):
1964
+ """
1965
+ Overrides _DrawableMirror.setSize.
1966
+ Scales the line endpoints proportionally to the new dimensions.
1967
+ """
1968
+ self._width = args.get('width', self._width)
1969
+ self._height = args.get('height', self._height)
1970
+
1971
+ # rebuild from original points, scaled to new dimensions
1972
+ scaleX = 0 if (self._originalWidth == 0) else (self._width / self._originalWidth)
1973
+ scaleY = 0 if (self._originalHeight == 0) else (self._height / self._originalHeight)
1974
+ scaledX = [x * scaleX for x in self._xPoints]
1975
+ scaledY = [y * scaleY for y in self._yPoints]
1976
+
1977
+ self._qObject.prepareGeometryChange()
1978
+ self._qObject.setLine(scaledX[0], scaledY[0], scaledX[1], scaledY[1])
1979
+
1980
+
1981
+ #######################################################################################
1982
+ # PolylineMirror — mirror of gui.py's Polyline
1983
+ #######################################################################################
1984
+
1985
+ class PolylineMirror(_GraphicsMirror):
1986
+ """
1987
+ Mirror of gui.py's Polyline. Backed by a QGraphicsPathItem.
1988
+
1989
+ Constructor args expected in the 'args' dict:
1990
+ x, y — pre-rotation top-left corner (pre-processed by gui.py)
1991
+ width, height — dimensions
1992
+ xPoints — x-coordinates relative to the corner (pre-processed by gui.py)
1993
+ yPoints — y-coordinates relative to the corner (pre-processed by gui.py)
1994
+ color — [r, g, b, a]
1995
+ thickness — int (line width)
1996
+ rotation — degrees (CCW visual, 0 = no rotation)
1997
+ """
1998
+
1999
+ def __init__(self, objectId, args, guiRenderer):
2000
+ super().__init__(objectId, args, guiRenderer)
2001
+
2002
+ rotation = args.get('rotation', 0)
2003
+
2004
+ self._localCornerX = args.get('x', 0)
2005
+ self._localCornerY = args.get('y', 0)
2006
+ self._width = args.get('width', 100)
2007
+ self._height = args.get('height', 100)
2008
+
2009
+ self._xPoints = args.get('xPoints', [0, 100])
2010
+ self._yPoints = args.get('yPoints', [0, 100])
2011
+
2012
+ # store original size (for rebuilding flattened geometry)
2013
+ self._originalWidth = self._width
2014
+ self._originalHeight = self._height
2015
+
2016
+ # build initial path
2017
+ path = self._buildPath(self._xPoints, self._yPoints)
2018
+ self._qObject = QtWidgets.QGraphicsPathItem(path)
2019
+
2020
+ # push initial values to Qt
2021
+ self._qObject.setPos(self._localCornerX, self._localCornerY)
2022
+ self._applyColor()
2023
+ self._applyThickness()
2024
+
2025
+ if rotation != 0:
2026
+ self.setRotation({'rotation': rotation}, responseId=None)
2027
+
2028
+ def _buildPath(self, xPoints, yPoints):
2029
+ """
2030
+ Builds a QPainterPath from local coordinate lists.
2031
+ """
2032
+ path = QtGui.QPainterPath()
2033
+ path.moveTo(xPoints[0], yPoints[0])
2034
+ for i in range(1, len(xPoints)):
2035
+ path.lineTo(xPoints[i], yPoints[i])
2036
+ return path
2037
+
2038
+ def setSize(self, args, responseId):
2039
+ """
2040
+ Overrides _DrawableMirror.setSize.
2041
+ Prefers scaling via QTransform; falls back to rebuilding the path
2042
+ from original points if either current dimension is 0.
2043
+ """
2044
+ width = args.get('width', self._width)
2045
+ height = args.get('height', self._height)
2046
+
2047
+ if self._width != 0 and self._height != 0:
2048
+ scaleX = width / self._width
2049
+ scaleY = height / self._height
2050
+ oldPath = self._qObject.path()
2051
+ oldRect = oldPath.boundingRect()
2052
+ transform = QtGui.QTransform()
2053
+ transform.translate(-oldRect.x(), -oldRect.y())
2054
+ transform.scale(scaleX, scaleY)
2055
+ transform.translate(oldRect.x(), oldRect.y())
2056
+ path = transform.map(oldPath)
2057
+ else:
2058
+ # rebuild from original points, scaled to new dimensions
2059
+ scaleX = 0 if (self._originalWidth == 0) else (width / self._originalWidth)
2060
+ scaleY = 0 if (self._originalHeight == 0) else (height / self._originalHeight)
2061
+ scaledX = [x * scaleX for x in self._xPoints]
2062
+ scaledY = [y * scaleY for y in self._yPoints]
2063
+ path = self._buildPath(scaledX, scaledY)
2064
+
2065
+ self._qObject.prepareGeometryChange()
2066
+ self._qObject.setPath(path)
2067
+ self._width = width
2068
+ self._height = height
2069
+
2070
+
2071
+ #######################################################################################
2072
+ # PolygonMirror — mirror of gui.py's Polygon
2073
+ #######################################################################################
2074
+
2075
+ class PolygonMirror(_GraphicsMirror):
2076
+ """
2077
+ Mirror of gui.py's Polygon. Backed by a QGraphicsPolygonItem.
2078
+
2079
+ Constructor args expected in the 'args' dict:
2080
+ x, y — pre-rotation top-left corner (pre-processed by gui.py)
2081
+ width, height — dimensions
2082
+ xPoints — x-coordinates relative to the corner (pre-processed by gui.py)
2083
+ yPoints — y-coordinates relative to the corner (pre-processed by gui.py)
2084
+ color — [r, g, b, a]
2085
+ fill — bool
2086
+ thickness — int (line width)
2087
+ rotation — degrees (CCW visual, 0 = no rotation)
2088
+ """
2089
+
2090
+ def __init__(self, objectId, args, guiRenderer):
2091
+ super().__init__(objectId, args, guiRenderer)
2092
+
2093
+ rotation = args.get('rotation', 0)
2094
+
2095
+ self._localCornerX = args.get('x', 0)
2096
+ self._localCornerY = args.get('y', 0)
2097
+ self._width = args.get('width', 100)
2098
+ self._height = args.get('height', 100)
2099
+
2100
+ self._xPoints = args.get('xPoints', [0, 100, 50])
2101
+ self._yPoints = args.get('yPoints', [0, 0, 100])
2102
+
2103
+ # store original size (for rebuilding flattened geometry)
2104
+ self._originalWidth = self._width
2105
+ self._originalHeight = self._height
2106
+
2107
+ # build initial polygon
2108
+ polygon = self._buildPolygon(self._xPoints, self._yPoints)
2109
+ self._qObject = QtWidgets.QGraphicsPolygonItem(polygon)
2110
+
2111
+ # push initial values to Qt
2112
+ self._qObject.setPos(self._localCornerX, self._localCornerY)
2113
+ self._applyColor()
2114
+ self._applyThickness()
2115
+
2116
+ if rotation != 0:
2117
+ self.setRotation({"rotation": rotation}, responseId=None)
2118
+
2119
+ def _buildPolygon(self, xPoints, yPoints):
2120
+ """
2121
+ Builds a QPolygonF from local coordinate lists.
2122
+ """
2123
+ polygon = QtGui.QPolygonF()
2124
+ for i in range(len(xPoints)):
2125
+ polygon.append(QtCore.QPointF(xPoints[i], yPoints[i]))
2126
+ return polygon
2127
+
2128
+ def setSize(self, args, responseId):
2129
+ """
2130
+ Overrides _DrawableMirror.setSize.
2131
+ Prefers scaling via QTransform; falls back to rebuilding the polygon
2132
+ from original points if either current dimension is 0.
2133
+ """
2134
+ width = args.get("width", self._width)
2135
+ height = args.get("height", self._height)
2136
+
2137
+ if self._width != 0 and self._height != 0:
2138
+ scaleX = width / self._width
2139
+ scaleY = height / self._height
2140
+ oldPolygon = self._qObject.polygon()
2141
+ oldRect = oldPolygon.boundingRect()
2142
+ transform = QtGui.QTransform()
2143
+ transform.translate(-oldRect.x(), -oldRect.y())
2144
+ transform.scale(scaleX, scaleY)
2145
+ transform.translate(oldRect.x(), oldRect.y())
2146
+ polygon = transform.map(oldPolygon)
2147
+ else:
2148
+ # rebuild from original points, scaled to new dimensions
2149
+ scaleX = 0 if (self._originalWidth == 0) else (width / self._originalWidth)
2150
+ scaleY = 0 if (self._originalHeight == 0) else (height / self._originalHeight)
2151
+ scaledX = [x * scaleX for x in self._xPoints]
2152
+ scaledY = [y * scaleY for y in self._yPoints]
2153
+ polygon = self._buildPolygon(scaledX, scaledY)
2154
+
2155
+ self._qObject.prepareGeometryChange()
2156
+ self._qObject.setPolygon(polygon)
2157
+ self._width = width
2158
+ self._height = height
2159
+
2160
+
2161
+ #######################################################################################
2162
+ # IconMirror — mirror of gui.py's Icon
2163
+ #######################################################################################
2164
+
2165
+ class IconMirror(_GraphicsMirror):
2166
+ """
2167
+ Mirror of gui.py's Icon. Backed by a QGraphicsPixmapItem.
2168
+
2169
+ Unlike other mirrors, Icon does most of its work on the Qt side because pixel
2170
+ operations must happen where the QPixmap lives. This means IconMirror has
2171
+ getters (getPixel, getPixels) that send responses back through the pipe.
2172
+
2173
+ Extends _GraphicsMirror for rotation support, but overrides _applyColor and
2174
+ _applyThickness as no-ops because QGraphicsPixmapItem has no pen or brush.
2175
+
2176
+ Constructor args expected in the 'args' dict:
2177
+ filename — path to image file (str)
2178
+ width — optional target width (int or None)
2179
+ height — optional target height (int or None)
2180
+ rotation — degrees (CCW)
2181
+ """
2182
+
2183
+ def __init__(self, objectId, args, guiRenderer):
2184
+ super().__init__(objectId, args, guiRenderer)
2185
+
2186
+ filename = args.get('filename', '')
2187
+ width = args.get('width')
2188
+ height = args.get('height')
2189
+ rotation = args.get('rotation')
2190
+
2191
+ # build pixmap
2192
+ pixmap = QtGui.QPixmap(filename)
2193
+
2194
+ if pixmap.isNull():
2195
+ # file failed to load — create blank pixmap
2196
+ if width is None:
2197
+ width = 600
2198
+ if height is None:
2199
+ height = 400
2200
+ pixmap = QtGui.QPixmap(width, height)
2201
+
2202
+ # resolve width/height from pixmap if not specified
2203
+ if width is None and height is None:
2204
+ width = pixmap.width()
2205
+ height = pixmap.height()
2206
+ elif width is None:
2207
+ width = int(pixmap.width() * (height / pixmap.height()))
2208
+ elif height is None:
2209
+ height = int(pixmap.height() * (width / pixmap.width()))
2210
+
2211
+ scaledPixmap = pixmap.scaled(width, height)
2212
+
2213
+ self._qObject = QtWidgets.QGraphicsPixmapItem(scaledPixmap)
2214
+ self._pixmap = pixmap # original (unscaled) pixmap for quality rescaling
2215
+
2216
+ # initialize geometry state
2217
+ self._localCornerX = 0
2218
+ self._localCornerY = 0
2219
+ self._width = width
2220
+ self._height = height
2221
+
2222
+ if rotation != 0:
2223
+ self.setRotation({'rotation': rotation}, responseId=None)
2224
+
2225
+ # add Icon-specific command handlers
2226
+ self._commandHandlers.update({
2227
+ 'crop': self.crop,
2228
+ 'getPixel': self.getPixel,
2229
+ 'setPixel': self.setPixel,
2230
+ 'getPixels': self.getPixels,
2231
+ 'setPixels': self.setPixels,
2232
+ 'write': self.write,
2233
+ })
2234
+
2235
+ def _applyColor(self):
2236
+ """No-op — QGraphicsPixmapItem has no pen or brush."""
2237
+ pass
2238
+
2239
+ def _applyThickness(self):
2240
+ """No-op — QGraphicsPixmapItem has no pen or brush."""
2241
+ pass
2242
+
2243
+ # ── Size ───────────────────────────────────────────────────────────────────
2244
+
2245
+ def setSize(self, args, responseId):
2246
+ """
2247
+ Rescales the pixmap from the original for quality.
2248
+ """
2249
+ width = args.get('width', self._width)
2250
+ height = args.get('height', self._height)
2251
+ if width > 0 and height > 0:
2252
+ scaledPixmap = self._pixmap.scaled(int(width), int(height))
2253
+ self._qObject.prepareGeometryChange()
2254
+ self._qObject.setPixmap(scaledPixmap)
2255
+ self._width = int(width)
2256
+ self._height = int(height)
2257
+
2258
+ # ── Crop ───────────────────────────────────────────────────────────────────
2259
+
2260
+ def crop(self, args, responseId):
2261
+ """
2262
+ Crops the original pixmap, then rescales to the cropped dimensions.
2263
+ """
2264
+ x = args.get('x', 0)
2265
+ y = args.get('y', 0)
2266
+ width = args.get('width', self._width)
2267
+ height = args.get('height', self._height)
2268
+
2269
+ self._pixmap = self._pixmap.copy(x, y, width, height)
2270
+ scaledPixmap = self._pixmap.scaled(width, height)
2271
+ self._qObject.setPixmap(scaledPixmap)
2272
+ self._width = width
2273
+ self._height = height
2274
+
2275
+ # ── Pixel getters / setters ────────────────────────────────────────────────
2276
+
2277
+ def getPixel(self, args, responseId):
2278
+ """
2279
+ Returns [r, g, b] for the pixel at (column, row) on the original pixmap.
2280
+ """
2281
+ column = args.get('column', 0)
2282
+ row = args.get('row', 0)
2283
+ image = self._pixmap.toImage()
2284
+ color = image.pixelColor(column, row)
2285
+ self._guiRenderer.sendResponse(responseId, [color.red(), color.green(), color.blue()])
2286
+
2287
+ def setPixel(self, args, responseId):
2288
+ """
2289
+ Sets the pixel at (column, row) to [r, g, b] on the original pixmap,
2290
+ then rescales to current display dimensions.
2291
+ """
2292
+ column = args.get('column', 0)
2293
+ row = args.get('row', 0)
2294
+ color = args.get('color', [0, 0, 0])
2295
+ r, g, b = color
2296
+ qtColor = QtGui.QColor(r, g, b, 255)
2297
+
2298
+ image = self._pixmap.toImage()
2299
+ image.setPixelColor(column, row, qtColor)
2300
+ self._pixmap = QtGui.QPixmap(image)
2301
+
2302
+ scaledPixmap = self._pixmap.scaled(self._width, self._height)
2303
+ self._qObject.setPixmap(scaledPixmap)
2304
+
2305
+ def getPixels(self, args, responseId):
2306
+ """
2307
+ Returns all pixels as a 2D list of [r, g, b] values from the original pixmap.
2308
+ """
2309
+ image = self._pixmap.toImage()
2310
+ image = image.convertToFormat(QtGui.QImage.Format.Format_RGBA8888)
2311
+ width = image.width()
2312
+ height = image.height()
2313
+
2314
+ pixels = []
2315
+ for row in range(height):
2316
+ rowPixels = []
2317
+ for col in range(width):
2318
+ color = image.pixelColor(col, row)
2319
+ rowPixels.append([color.red(), color.green(), color.blue()])
2320
+ pixels.append(rowPixels)
2321
+
2322
+ self._guiRenderer.sendResponse(responseId, pixels)
2323
+
2324
+ def setPixels(self, args, responseId):
2325
+ """
2326
+ Sets all pixels from a 2D list of [r, g, b] values.
2327
+ Rebuilds the pixmap and rescales to current display dimensions.
2328
+ """
2329
+ pixels = args.get('pixels', [])
2330
+ if not pixels:
2331
+ return
2332
+
2333
+ height = len(pixels)
2334
+ width = len(pixels[0])
2335
+
2336
+ image = QtGui.QImage(width, height, QtGui.QImage.Format.Format_RGBA8888)
2337
+ for row in range(height):
2338
+ for col in range(width):
2339
+ rgb = pixels[row][col]
2340
+ r, g, b = rgb[0], rgb[1], rgb[2]
2341
+ image.setPixelColor(col, row, QtGui.QColor(r, g, b, 255))
2342
+
2343
+ self._pixmap = QtGui.QPixmap(image)
2344
+ scaledPixmap = self._pixmap.scaled(self._width, self._height)
2345
+ self._qObject.setPixmap(scaledPixmap)
2346
+
2347
+ # ── Write ──────────────────────────────────────────────────────────────────
2348
+
2349
+ def write(self, args, responseId):
2350
+ """
2351
+ Saves the icon's original pixmap to a file.
2352
+ Optional width/height args resize the output; omitting one preserves aspect ratio.
2353
+ Sends [success (bool), resolvedPath (str)] back to the parent process.
2354
+ """
2355
+ import os
2356
+ filename = args.get('filename', 'icon.png')
2357
+ width = args.get('width')
2358
+ height = args.get('height')
2359
+
2360
+ pixmap = self._pixmap
2361
+
2362
+ if width is not None or height is not None:
2363
+ origW = pixmap.width()
2364
+ origH = pixmap.height()
2365
+ width = int(origW * (height / origH)) if width is None else int(width)
2366
+ height = int(origH * (width / origW)) if height is None else int(height)
2367
+ pixmap = pixmap.scaled(width, height,
2368
+ QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
2369
+ QtCore.Qt.TransformationMode.SmoothTransformation)
2370
+
2371
+ success = pixmap.save(filename)
2372
+ resolvedPath = os.path.abspath(filename)
2373
+ self._guiRenderer.sendResponse(responseId, [success, resolvedPath])
2374
+
2375
+
2376
+ #######################################################################################
2377
+ # LabelMirror — mirror of gui.py's Label
2378
+ #######################################################################################
2379
+
2380
+ class LabelMirror(_GraphicsMirror):
2381
+ """
2382
+ Mirror of gui.py's Label. Backed by a QGraphicsItemGroup containing
2383
+ a QGraphicsTextItem (foreground text) and a QGraphicsRectItem (background).
2384
+
2385
+ Overrides _applyColor (text color), _applyThickness (background pen), and
2386
+ _setFill (background visibility) because Label's Qt structure differs from
2387
+ simple shape items.
2388
+
2389
+ Constructor args expected in the 'args' dict:
2390
+ text — string
2391
+ alignment — int (Qt.AlignmentFlag value: 1=Left, 132=Center, 2=Right)
2392
+ textColor — [r, g, b, a]
2393
+ backgroundColor — [r, g, b, a]
2394
+ font — None or [name, [weight, italic], size]
2395
+ """
2396
+
2397
+ def __init__(self, objectId, args, guiRenderer):
2398
+ super().__init__(objectId, args, guiRenderer)
2399
+
2400
+ text = str(args.get('text', ''))
2401
+ alignment = args.get('alignment', 1) # 1 = AlignLeft
2402
+ textColor = args.get('textColor', [0, 0, 0, 255])
2403
+ backgroundColor = args.get('backgroundColor', [0, 0, 0, 0])
2404
+ font = args.get('font')
2405
+
2406
+ self._color = textColor
2407
+ self._backgroundColor = backgroundColor
2408
+
2409
+ # create foreground text
2410
+ self._qTextObject = QtWidgets.QGraphicsTextItem(text)
2411
+ r, g, b, a = textColor
2412
+ self._qTextObject.setDefaultTextColor(QtGui.QColor(r, g, b, a))
2413
+
2414
+ # create background rectangle (matches text bounds)
2415
+ self._qBackgroundObject = QtWidgets.QGraphicsRectItem(
2416
+ self._qTextObject.boundingRect()
2417
+ )
2418
+ r, g, b, a = backgroundColor
2419
+ self._qBackgroundObject.setBrush(QtGui.QColor(r, g, b, a))
2420
+ self._qBackgroundObject.setPen(QtCore.Qt.PenStyle.NoPen)
2421
+
2422
+ # create graphics container (group is the main _qObject)
2423
+ self._qObject = QtWidgets.QGraphicsItemGroup()
2424
+ self._qObject.addToGroup(self._qBackgroundObject)
2425
+ self._qObject.addToGroup(self._qTextObject)
2426
+
2427
+ # initialize geometry from text bounds
2428
+ bounds = self._qTextObject.boundingRect()
2429
+ self._localCornerX = 0
2430
+ self._localCornerY = 0
2431
+ self._width = int(bounds.width())
2432
+ self._height = int(bounds.height())
2433
+
2434
+ # apply alignment
2435
+ self._setAlignmentValue(alignment)
2436
+
2437
+ # apply font if provided
2438
+ if font is not None:
2439
+ self._applyFont(font)
2440
+
2441
+ # push initial position
2442
+ self._qObject.setPos(0, 0)
2443
+
2444
+ # add Label-specific command handlers
2445
+ self._commandHandlers.update({
2446
+ 'setText': self.setText,
2447
+ 'setBackgroundColor': self.setBackgroundColor,
2448
+ 'setAlignment': self.setAlignment,
2449
+ 'setFont': self.setFont,
2450
+ })
2451
+
2452
+ # ── Overrides ──────────────────────────────────────────────────────────────
2453
+
2454
+ def _applyColor(self):
2455
+ """Sets the text color (not pen/brush — Labels use setDefaultTextColor)."""
2456
+ r, g, b, a = self._color
2457
+ self._qTextObject.setDefaultTextColor(QtGui.QColor(r, g, b, a))
2458
+
2459
+ def _applyThickness(self):
2460
+ """Sets the background rectangle's pen width."""
2461
+ qPen = self._qBackgroundObject.pen()
2462
+ qPen.setWidth(self._thickness)
2463
+ self._qBackgroundObject.setPen(qPen)
2464
+
2465
+ def setFill(self, args, responseId):
2466
+ """Controls background visibility. When unfilled, background is transparent."""
2467
+ self._fill = bool(args.get('fill', False))
2468
+ if self._fill:
2469
+ r, g, b, a = self._backgroundColor
2470
+ qColor = QtGui.QColor(r, g, b, a)
2471
+ else:
2472
+ qColor = QtGui.QColor(0, 0, 0, 0)
2473
+ self._qBackgroundObject.setBrush(QtGui.QBrush(qColor))
2474
+
2475
+ # ── Text ───────────────────────────────────────────────────────────────────
2476
+
2477
+ def setText(self, args, responseId):
2478
+ """Sets the label's text and updates the background rect to match."""
2479
+ text = str(args.get('text', ''))
2480
+ self._qTextObject.setPlainText(text)
2481
+ self._updateBackground()
2482
+
2483
+ def _updateBackground(self):
2484
+ """Resizes the background rect to match the current text bounds."""
2485
+ bounds = self._qTextObject.boundingRect()
2486
+ self._qBackgroundObject.setRect(bounds)
2487
+ self._width = int(bounds.width())
2488
+ self._height = int(bounds.height())
2489
+
2490
+ # ── Background color ──────────────────────────────────────────────────────
2491
+
2492
+ def setBackgroundColor(self, args, responseId):
2493
+ """Sets the background rectangle's fill color."""
2494
+ self._backgroundColor = args.get('color', [0, 0, 0, 0])
2495
+ r, g, b, a = self._backgroundColor
2496
+ self._qBackgroundObject.setBrush(QtGui.QColor(r, g, b, a))
2497
+
2498
+ # ── Alignment ──────────────────────────────────────────────────────────────
2499
+
2500
+ def setAlignment(self, args, responseId):
2501
+ """Sets text alignment from an integer Qt.AlignmentFlag value."""
2502
+ alignment = args.get('alignment', 1)
2503
+ self._setAlignmentValue(alignment)
2504
+
2505
+ def _setAlignmentValue(self, alignment):
2506
+ """Applies alignment to the text item's document."""
2507
+ qtFlag = QtCore.Qt.AlignmentFlag(alignment)
2508
+ document = self._qTextObject.document()
2509
+ textOption = document.defaultTextOption()
2510
+ textOption.setAlignment(qtFlag)
2511
+ document.setDefaultTextOption(textOption)
2512
+
2513
+ # ── Font ───────────────────────────────────────────────────────────────────
2514
+
2515
+ def setFont(self, args, responseId):
2516
+ """Sets the text font from [name, [weight, italic], size]."""
2517
+ font = args.get('font')
2518
+ if font is not None:
2519
+ self._applyFont(font)
2520
+ self._updateBackground()
2521
+
2522
+ def _applyFont(self, font):
2523
+ """Constructs a QFont and applies it to the text item."""
2524
+ name, style, size = font
2525
+ weight, italic = style
2526
+ qFont = QtGui.QFont(name, size)
2527
+ qFont.setWeight(QtGui.QFont.Weight(weight))
2528
+ qFont.setItalic(italic)
2529
+ self._qTextObject.setFont(qFont)
2530
+
2531
+
2532
+ #######################################################################################
2533
+ # GroupMirror — mirror of gui.py's Group
2534
+ #######################################################################################
2535
+
2536
+ class GroupMirror(_DrawableMirror):
2537
+ """
2538
+ Mirror of gui.py's Group. Backed by a QGraphicsItemGroup.
2539
+
2540
+ Groups contain other drawable mirrors as children. Adding a child attaches
2541
+ its _qObject to this group's QGraphicsItemGroup; removing detaches it.
2542
+
2543
+ Group extends _DrawableMirror (not _GraphicsMirror) because gui.py's Group
2544
+ inherits from _Drawable, not _Graphics — it has no color, fill, or thickness.
2545
+
2546
+ Constructor args expected in the 'args' dict:
2547
+ (none — children are added via addChild / addChildOrder commands)
2548
+ """
2549
+
2550
+ def __init__(self, objectId, args, guiRenderer):
2551
+ super().__init__(objectId, guiRenderer)
2552
+
2553
+ self._qObject = QtWidgets.QGraphicsItemGroup()
2554
+ self._itemList = []
2555
+
2556
+ # initialize geometry
2557
+ self._localCornerX = 0
2558
+ self._localCornerY = 0
2559
+ self._width = 0
2560
+ self._height = 0
2561
+
2562
+ self._qObject.setPos(0, 0)
2563
+
2564
+ # add Group-specific command handlers
2565
+ self._commandHandlers.update({
2566
+ 'addChild': self.addChild,
2567
+ 'addChildOrder': self.addChildOrder,
2568
+ 'removeChild': self.removeChild,
2569
+ 'getOrder': self.getOrder,
2570
+ 'setOrder': self.setOrder,
2571
+ })
2572
+
2573
+ # ── Child management ───────────────────────────────────────────────────────
2574
+
2575
+ def addChild(self, args, responseId):
2576
+ """Adds a child at the top of the z-order (order = 0)."""
2577
+ itemId = args.get('itemId')
2578
+ addChildArgs = {'itemId': itemId, 'order': 0}
2579
+ self.addChildOrder(addChildArgs, responseId=None)
2580
+
2581
+ def addChildOrder(self, args, responseId):
2582
+ """Adds a child at the specified z-order position."""
2583
+ itemId = args.get('itemId')
2584
+ order = args.get('order', 0)
2585
+
2586
+ item = self._guiRenderer._objectRegistry.get(itemId)
2587
+ if item is None:
2588
+ return
2589
+
2590
+ # remove from this group if already present
2591
+ if item in self._itemList:
2592
+ self._itemList.remove(item)
2593
+ self._qObject.removeFromGroup(item._qObject)
2594
+
2595
+ # clamp order and insert
2596
+ order = max(0, min(len(self._itemList), order))
2597
+ self._itemList.insert(order, item)
2598
+
2599
+ # calculate z-value (same algorithm as DisplayMirror)
2600
+ if order == 0:
2601
+ qZValue = 1.0
2602
+ if len(self._itemList) > 1:
2603
+ neighbor = self._itemList[1]
2604
+ qZValue = neighbor._qZValue + 1.0
2605
+
2606
+ elif order >= len(self._itemList) - 1:
2607
+ qZValue = 0.0
2608
+ if len(self._itemList) > 1:
2609
+ neighbor = self._itemList[-2]
2610
+ qZValue = neighbor._qZValue - 1.0
2611
+
2612
+ else:
2613
+ frontNeighbor = self._itemList[order - 1]
2614
+ backNeighbor = self._itemList[order + 1]
2615
+ qZValue = (frontNeighbor._qZValue + backNeighbor._qZValue) / 2.0
2616
+
2617
+ item._qZValue = qZValue
2618
+ item._qObject.setZValue(qZValue)
2619
+ self._qObject.addToGroup(item._qObject)
2620
+
2621
+ cacheMode = QtWidgets.QGraphicsItem.CacheMode.DeviceCoordinateCache
2622
+ item._qObject.setCacheMode(cacheMode)
2623
+
2624
+ def removeChild(self, args, responseId):
2625
+ """Removes a child from the group."""
2626
+ itemId = args.get('itemId')
2627
+ item = self._guiRenderer._objectRegistry.get(itemId)
2628
+ if item is not None and item in self._itemList:
2629
+ self._itemList.remove(item)
2630
+ self._qObject.removeFromGroup(item._qObject)
2631
+
2632
+ def getOrder(self, args, responseId):
2633
+ """Returns the order of a child in the group."""
2634
+ itemId = args.get('itemId')
2635
+ order = None
2636
+ for i, item in enumerate(self._itemList):
2637
+ if item.objectId == itemId:
2638
+ order = i
2639
+ break
2640
+ self._guiRenderer.sendResponse(responseId, [order])
2641
+
2642
+ def setOrder(self, args, responseId):
2643
+ """Changes a child's z-order by removing and re-adding at the new position."""
2644
+ itemId = args.get('itemId')
2645
+ order = args.get('order', 0)
2646
+ addChildArgs = {'itemId': itemId, 'order': order}
2647
+ self.addChildOrder(addChildArgs, responseId=None)
2648
+
2649
+
2650
+ #######################################################################################
2651
+ # _ControlMirror — base mirror for all Control widgets
2652
+ #######################################################################################
2653
+
2654
+ class _ControlMirror:
2655
+ """
2656
+ Base class for all Control mirror objects (QWidgets, not QGraphicsItems).
2657
+ Parallel to _DrawableMirror but uses QWidget APIs: .move() for position,
2658
+ .setFixedSize() for dimensions, and stylesheets for color.
2659
+
2660
+ Controls have no hit-testing (contains/intersects/encloses) because QWidgets
2661
+ handle their own input directly.
2662
+
2663
+ Concrete classes must:
2664
+ 1. Call super().__init__(objectId, args, guiRenderer) first.
2665
+ 2. Create self._qObject (the underlying QWidget).
2666
+ """
2667
+
2668
+ _isControl = True # tells DisplayMirror to use setParent/show instead of scene.addItem
2669
+
2670
+ def __init__(self, objectId, args, guiRenderer):
2671
+ self.objectId = objectId
2672
+ self._guiRenderer = guiRenderer
2673
+ self._qObject = None # set by concrete class constructor
2674
+ self._qZValue = 0.0 # assigned by DisplayMirror._addOrder
2675
+ self._localCornerX = 0
2676
+ self._localCornerY = 0
2677
+ self._width = 0
2678
+ self._height = 0
2679
+ self._toolTipText = None
2680
+
2681
+ self._commandHandlers = {
2682
+ 'setPosition': self.setPosition,
2683
+ 'getSize': self.getSize,
2684
+ 'setSize': self.setSize,
2685
+ 'setToolTipText': self.setToolTipText,
2686
+ }
2687
+
2688
+ def handleCommand(self, action, args, responseId):
2689
+ """Dispatches an incoming command to the appropriate handler method."""
2690
+ handler = self._commandHandlers.get(action)
2691
+ if handler is not None:
2692
+ handler(args, responseId)
2693
+
2694
+ # ── Position ───────────────────────────────────────────────────────────────
2695
+
2696
+ def setPosition(self, args, responseId):
2697
+ x = int(args.get('x', self._localCornerX))
2698
+ y = int(args.get('y', self._localCornerY))
2699
+ self._localCornerX = x
2700
+ self._localCornerY = y
2701
+ self._qObject.move(x, y)
2702
+
2703
+ # ── Size ───────────────────────────────────────────────────────────────────
2704
+
2705
+ def getSize(self, args, responseId):
2706
+ self._guiRenderer.sendResponse(responseId, [self._width, self._height])
2707
+
2708
+ def setSize(self, args, responseId):
2709
+ width = int(args.get('width', self._width))
2710
+ height = int(args.get('height', self._height))
2711
+ if width > 0 and height > 0:
2712
+ self._qObject.setFixedSize(width, height)
2713
+ self._width = width
2714
+ self._height = height
2715
+
2716
+ # ── Tooltip ────────────────────────────────────────────────────────────────
2717
+
2718
+ def setToolTipText(self, args, responseId):
2719
+ text = args.get('text')
2720
+ self._toolTipText = text
2721
+ self._qObject.setToolTip(text)
2722
+
2723
+ # ── Event helper ───────────────────────────────────────────────────────────
2724
+
2725
+ def _forwardEvent(self, eventType, eventArgs=None):
2726
+ """
2727
+ Checks if the event is registered, and if so, sends it to the parent process.
2728
+ Connected to Qt signals in the concrete class constructor.
2729
+ """
2730
+ isRegistered = (self.objectId, eventType) in self._guiRenderer._registeredEvents
2731
+ if isRegistered:
2732
+ self._guiRenderer.sendEvent(eventType, self.objectId, eventArgs or {})
2733
+
2734
+
2735
+ #######################################################################################
2736
+ # ButtonMirror — mirror of gui.py's Button
2737
+ #######################################################################################
2738
+
2739
+ class ButtonMirror(_ControlMirror):
2740
+ """
2741
+ Mirror of gui.py's Button. Backed by a QPushButton.
2742
+
2743
+ Constructor args expected in the 'args' dict:
2744
+ text — string
2745
+ color — [r, g, b, a]
2746
+ """
2747
+
2748
+ def __init__(self, objectId, args, guiRenderer):
2749
+ super().__init__(objectId, args, guiRenderer)
2750
+
2751
+ text = args.get('text', '')
2752
+ color = args.get('color', [211, 211, 211, 255]) # LIGHT_GRAY default
2753
+
2754
+ self._qObject = QtWidgets.QPushButton()
2755
+ self._qObject.setText(text)
2756
+ self._qObject.adjustSize()
2757
+ self._width = self._qObject.width()
2758
+ self._height = self._qObject.height()
2759
+
2760
+ self._applyColor(color)
2761
+
2762
+ # wire Qt signal → event forwarding
2763
+ self._qObject.clicked.connect(lambda: self._forwardEvent('clicked'))
2764
+
2765
+ self._commandHandlers.update({
2766
+ 'setText': self.setText,
2767
+ 'getText': self.getText,
2768
+ 'setColor': self.setColor,
2769
+ })
2770
+
2771
+ def _applyColor(self, color):
2772
+ r, g, b, a = color
2773
+ dr = max(0, int(r * 0.9))
2774
+ dg = max(0, int(g * 0.9))
2775
+ db = max(0, int(b * 0.9))
2776
+ self._qObject.setStyleSheet(
2777
+ f"QPushButton {{ background-color: rgba({r},{g},{b},{a}); color: black; }}"
2778
+ f"QPushButton::pressed {{ background-color: rgba({dr},{dg},{db},{a}); }}"
2779
+ )
2780
+
2781
+ def setColor(self, args, responseId):
2782
+ color = args.get('color', [211, 211, 211, 255])
2783
+ self._applyColor(color)
2784
+
2785
+ def setText(self, args, responseId):
2786
+ text = args.get('text', '')
2787
+ self._qObject.setText(text)
2788
+ self._qObject.adjustSize()
2789
+ self._width = self._qObject.width()
2790
+ self._height = self._qObject.height()
2791
+
2792
+ def getText(self, args, responseId):
2793
+ self._guiRenderer.sendResponse(responseId, [self._qObject.text()])
2794
+
2795
+
2796
+ #######################################################################################
2797
+ # CheckBoxMirror — mirror of gui.py's CheckBox
2798
+ #######################################################################################
2799
+
2800
+ class CheckBoxMirror(_ControlMirror):
2801
+ """
2802
+ Mirror of gui.py's CheckBox. Backed by a QCheckBox.
2803
+
2804
+ Constructor args expected in the 'args' dict:
2805
+ text — string
2806
+ color — [r, g, b, a]
2807
+ """
2808
+
2809
+ def __init__(self, objectId, args, guiRenderer):
2810
+ super().__init__(objectId, args, guiRenderer)
2811
+
2812
+ text = args.get('text', '')
2813
+ color = args.get('color', [0, 0, 0, 0]) # CLEAR default
2814
+
2815
+ self._qObject = QtWidgets.QCheckBox(text)
2816
+ self._qObject.adjustSize()
2817
+ self._width = self._qObject.width()
2818
+ self._height = self._qObject.height()
2819
+
2820
+ self._applyColor(color)
2821
+
2822
+ self._qObject.stateChanged.connect(
2823
+ lambda state: self._forwardEvent('stateChanged', {'checked': state != 0})
2824
+ )
2825
+
2826
+ self._commandHandlers.update({
2827
+ 'setText': self.setText,
2828
+ 'getText': self.getText,
2829
+ 'setColor': self.setColor,
2830
+ 'isChecked': self.isChecked,
2831
+ 'check': self.check,
2832
+ 'uncheck': self.uncheck,
2833
+ })
2834
+
2835
+ def _applyColor(self, color):
2836
+ r, g, b, a = color
2837
+ self._qObject.setStyleSheet(
2838
+ f"QCheckBox {{ background-color: rgba({r},{g},{b},{a}); color: black; }}"
2839
+ )
2840
+
2841
+ def setColor(self, args, responseId):
2842
+ color = args.get('color', [0, 0, 0, 0])
2843
+ self._applyColor(color)
2844
+
2845
+ def setText(self, args, responseId):
2846
+ text = args.get('text', '')
2847
+ self._qObject.setText(text)
2848
+ self._qObject.adjustSize()
2849
+ self._width = self._qObject.width()
2850
+ self._height = self._qObject.height()
2851
+
2852
+ def getText(self, args, responseId):
2853
+ self._guiRenderer.sendResponse(responseId, [self._qObject.text()])
2854
+
2855
+ def isChecked(self, args, responseId):
2856
+ self._guiRenderer.sendResponse(responseId, [self._qObject.isChecked()])
2857
+
2858
+ def check(self, args, responseId):
2859
+ self._qObject.setChecked(True)
2860
+
2861
+ def uncheck(self, args, responseId):
2862
+ self._qObject.setChecked(False)
2863
+
2864
+
2865
+ #######################################################################################
2866
+ # SliderMirror — mirror of gui.py's Slider
2867
+ #######################################################################################
2868
+
2869
+ class SliderMirror(_ControlMirror):
2870
+ """
2871
+ Mirror of gui.py's Slider. Backed by a QSlider.
2872
+
2873
+ Constructor args expected in the 'args' dict:
2874
+ orientation — int (1 = Horizontal, 2 = Vertical; matches Qt.Orientation values)
2875
+ minValue — int
2876
+ maxValue — int
2877
+ startValue — int
2878
+ """
2879
+
2880
+ def __init__(self, objectId, args, guiRenderer):
2881
+ super().__init__(objectId, args, guiRenderer)
2882
+
2883
+ orientation = args.get('orientation', 1) # 1 = Horizontal
2884
+ minValue = args.get('minValue', 0)
2885
+ maxValue = args.get('maxValue', 100)
2886
+ startValue = args.get('startValue')
2887
+
2888
+ if startValue is None:
2889
+ startValue = int((minValue + maxValue) / 2)
2890
+
2891
+ qtOrientation = QtCore.Qt.Orientation(orientation)
2892
+ self._qObject = QtWidgets.QSlider(qtOrientation)
2893
+ self._qObject.setRange(minValue, maxValue)
2894
+ self._qObject.setValue(startValue)
2895
+ self._qObject.adjustSize()
2896
+ self._width = self._qObject.width()
2897
+ self._height = self._qObject.height()
2898
+
2899
+ # wire Qt signal → event forwarding
2900
+ self._qObject.valueChanged.connect(
2901
+ lambda value: self._forwardEvent('valueChanged', {'value': value})
2902
+ )
2903
+
2904
+ self._commandHandlers.update({
2905
+ 'setValue': self._setValue,
2906
+ 'getValue': self._getValue,
2907
+ })
2908
+
2909
+ def _setValue(self, args, responseId):
2910
+ value = args.get('value', 0)
2911
+ self._qObject.setValue(value)
2912
+
2913
+ def _getValue(self, args, responseId):
2914
+ self._guiRenderer.sendResponse(responseId, [self._qObject.value()])
2915
+
2916
+
2917
+ #######################################################################################
2918
+ # DropDownListMirror — mirror of gui.py's DropDownList
2919
+ #######################################################################################
2920
+
2921
+ class DropDownListMirror(_ControlMirror):
2922
+ """
2923
+ Mirror of gui.py's DropDownList. Backed by a QComboBox.
2924
+
2925
+ Constructor args expected in the 'args' dict:
2926
+ items — list of strings
2927
+ color — [r, g, b, a]
2928
+ """
2929
+
2930
+ def __init__(self, objectId, args, guiRenderer):
2931
+ super().__init__(objectId, args, guiRenderer)
2932
+
2933
+ items = args.get('items', [])
2934
+ color = args.get('color', [211, 211, 211, 255]) # LIGHT_GRAY default
2935
+
2936
+ self._qObject = QtWidgets.QComboBox()
2937
+ self._qObject.addItems(items)
2938
+ self._qObject.adjustSize()
2939
+ self._width = self._qObject.width()
2940
+ self._height = self._qObject.height()
2941
+
2942
+ self._applyColor(color)
2943
+
2944
+ # wire Qt signal → event forwarding (sends selected index)
2945
+ self._qObject.activated.connect(
2946
+ lambda index: self._forwardEvent('activated', {'index': index})
2947
+ )
2948
+
2949
+ self._commandHandlers.update({
2950
+ 'setColor': self._setColor,
2951
+ })
2952
+
2953
+ def _applyColor(self, color):
2954
+ r, g, b, a = color
2955
+ self._qObject.setStyleSheet(
2956
+ f"QComboBox {{ background-color: rgba({r},{g},{b},{a}); color: black; }}"
2957
+ f"QComboBox QAbstractItemView {{ background-color: rgba({r},{g},{b},{a}); color: black; }}"
2958
+ )
2959
+
2960
+ def _setColor(self, args, responseId):
2961
+ color = args.get('color', [211, 211, 211, 255])
2962
+ self._applyColor(color)
2963
+
2964
+
2965
+ #######################################################################################
2966
+ # TextFieldMirror — mirror of gui.py's TextField
2967
+ #######################################################################################
2968
+
2969
+ class TextFieldMirror(_ControlMirror):
2970
+ """
2971
+ Mirror of gui.py's TextField. Backed by a QLineEdit.
2972
+
2973
+ Constructor args expected in the 'args' dict:
2974
+ text — string
2975
+ width — int (pre-computed by gui.py from columns + font metrics)
2976
+ height — int (pre-computed by gui.py)
2977
+ color — [r, g, b, a]
2978
+ font — None or [name, [weight, italic], size]
2979
+ """
2980
+
2981
+ def __init__(self, objectId, args, guiRenderer):
2982
+ super().__init__(objectId, args, guiRenderer)
2983
+
2984
+ text = args.get('text', '')
2985
+ width = args.get('width')
2986
+ height = args.get('height')
2987
+ color = args.get('color', [255, 255, 255, 255]) # WHITE default
2988
+ font = args.get('font')
2989
+
2990
+ self._qObject = QtWidgets.QLineEdit(str(text))
2991
+
2992
+ if font is not None:
2993
+ self._applyFont(font)
2994
+
2995
+ columns = args.get('columns')
2996
+
2997
+ if width is not None and height is not None:
2998
+ self._qObject.setFixedSize(width, height)
2999
+ self._width = width
3000
+ self._height = height
3001
+ elif columns is not None:
3002
+ fm = QtGui.QFontMetrics(self._qObject.font())
3003
+ charW = fm.horizontalAdvance('M')
3004
+ charH = fm.lineSpacing()
3005
+ margins = self._qObject.textMargins()
3006
+ hMargin = margins.left() + margins.right()
3007
+ vMargin = margins.top() + margins.bottom()
3008
+ frameOpt = QtWidgets.QStyleOptionFrame()
3009
+ self._qObject.initStyleOption(frameOpt)
3010
+ frame = self._qObject.style().pixelMetric(
3011
+ QtWidgets.QStyle.PixelMetric.PM_DefaultFrameWidth, frameOpt, self._qObject
3012
+ )
3013
+ w = (charW * columns) + hMargin + (2 * frame)
3014
+ h = charH + vMargin + (2 * frame)
3015
+ self._qObject.setFixedSize(w, h)
3016
+ self._width = w
3017
+ self._height = h
3018
+ else:
3019
+ self._qObject.adjustSize()
3020
+ self._width = self._qObject.width()
3021
+ self._height = self._qObject.height()
3022
+
3023
+ self._applyColor(color)
3024
+
3025
+ # wire Qt signal → event forwarding
3026
+ self._qObject.returnPressed.connect(
3027
+ lambda: self._forwardEvent('returnPressed', {'text': self._qObject.text()})
3028
+ )
3029
+
3030
+ self._commandHandlers.update({
3031
+ 'setText': self._setText,
3032
+ 'getText': self._getText,
3033
+ 'setColor': self._setColor,
3034
+ 'setFont': self._setFont,
3035
+ })
3036
+
3037
+ def _applyColor(self, color):
3038
+ r, g, b, a = color
3039
+ self._qObject.setStyleSheet(
3040
+ f"QLineEdit {{ background-color: rgba({r},{g},{b},{a}); color: black; }}"
3041
+ )
3042
+
3043
+ def _setColor(self, args, responseId):
3044
+ color = args.get('color', [255, 255, 255, 255])
3045
+ self._applyColor(color)
3046
+
3047
+ def _setText(self, args, responseId):
3048
+ text = args.get('text', '')
3049
+ self._qObject.setText(text)
3050
+
3051
+ def _getText(self, args, responseId):
3052
+ self._guiRenderer.sendResponse(responseId, [self._qObject.text()])
3053
+
3054
+ def _applyFont(self, font):
3055
+ name, style, size = font
3056
+ weight, italic = style
3057
+ qFont = QtGui.QFont(name, size)
3058
+ qFont.setWeight(QtGui.QFont.Weight(weight))
3059
+ qFont.setItalic(italic)
3060
+ self._qObject.setFont(qFont)
3061
+
3062
+ def _setFont(self, args, responseId):
3063
+ font = args.get('font')
3064
+ if font is not None:
3065
+ self._applyFont(font)
3066
+
3067
+
3068
+ #######################################################################################
3069
+ # TextAreaMirror — mirror of gui.py's TextArea
3070
+ #######################################################################################
3071
+
3072
+ class TextAreaMirror(_ControlMirror):
3073
+ """
3074
+ Mirror of gui.py's TextArea. Backed by a QTextEdit.
3075
+
3076
+ Constructor args expected in the 'args' dict:
3077
+ text — string
3078
+ width — int (pre-computed by gui.py from columns/rows + font metrics)
3079
+ height — int (pre-computed by gui.py)
3080
+ color — [r, g, b, a]
3081
+ font — None or [name, [weight, italic], size]
3082
+ """
3083
+
3084
+ def __init__(self, objectId, args, guiRenderer):
3085
+ super().__init__(objectId, args, guiRenderer)
3086
+
3087
+ text = args.get('text', '')
3088
+ width = args.get('width')
3089
+ height = args.get('height')
3090
+ color = args.get('color', [255, 255, 255, 255]) # WHITE default
3091
+ font = args.get('font')
3092
+
3093
+ columns = args.get('columns')
3094
+ rows = args.get('rows')
3095
+
3096
+ self._qObject = QtWidgets.QTextEdit(str(text))
3097
+
3098
+ if font is not None:
3099
+ self._applyFont(font)
3100
+
3101
+ if width is not None and height is not None:
3102
+ self._qObject.setFixedSize(width, height)
3103
+ self._width = width
3104
+ self._height = height
3105
+ elif columns is not None or rows is not None:
3106
+ fm = QtGui.QFontMetrics(self._qObject.font())
3107
+ w = fm.horizontalAdvance('M') * (columns or 8)
3108
+ h = fm.lineSpacing() * (rows or 5)
3109
+ self._qObject.setFixedSize(w, h)
3110
+ self._width = w
3111
+ self._height = h
3112
+ else:
3113
+ self._qObject.adjustSize()
3114
+ self._width = self._qObject.width()
3115
+ self._height = self._qObject.height()
3116
+
3117
+ self._applyColor(color)
3118
+
3119
+ self._commandHandlers.update({
3120
+ 'setText': self._setText,
3121
+ 'getText': self._getText,
3122
+ 'setColor': self._setColor,
3123
+ 'setFont': self._setFont,
3124
+ })
3125
+
3126
+ def _applyColor(self, color):
3127
+ r, g, b, a = color
3128
+ self._qObject.setStyleSheet(
3129
+ f"QTextEdit {{ background-color: rgba({r},{g},{b},{a}); color: black; }}"
3130
+ )
3131
+
3132
+ def _setColor(self, args, responseId):
3133
+ color = args.get('color', [255, 255, 255, 255])
3134
+ self._applyColor(color)
3135
+
3136
+ def _setText(self, args, responseId):
3137
+ text = args.get('text', '')
3138
+ self._qObject.setText(text)
3139
+
3140
+ def _getText(self, args, responseId):
3141
+ self._guiRenderer.sendResponse(responseId, [self._qObject.toPlainText()])
3142
+
3143
+ def _applyFont(self, font):
3144
+ name, style, size = font
3145
+ weight, italic = style
3146
+ qFont = QtGui.QFont(name, size)
3147
+ qFont.setWeight(QtGui.QFont.Weight(weight))
3148
+ qFont.setItalic(italic)
3149
+ self._qObject.setFont(qFont)
3150
+
3151
+ def _setFont(self, args, responseId):
3152
+ font = args.get('font')
3153
+ if font is not None:
3154
+ self._applyFont(font)
3155
+
3156
+ #######################################################################################
3157
+ # MenuMirror — mirror of gui.py's Menu
3158
+ #######################################################################################
3159
+
3160
+ class MenuMirror:
3161
+ """
3162
+ Mirror of gui.py's Menu class. Backed by a QMenu.
3163
+
3164
+ Menus are not placed on a Display via add() — they are attached to a
3165
+ Display's menu bar (addMenu command) or used as a right-click context menu
3166
+ (addPopupMenu command).
3167
+
3168
+ Constructor args expected in the 'args' dict:
3169
+ title — string
3170
+
3171
+ Item callbacks are routed back to gui.py via a single 'itemTriggered' event
3172
+ carrying {'itemIndex': int}. gui.py maps the index to the per-item callback.
3173
+ """
3174
+
3175
+ def __init__(self, objectId, args, guiRenderer):
3176
+ self.objectId = objectId
3177
+ self._guiRenderer = guiRenderer
3178
+
3179
+ title = args.get('title', '')
3180
+ self._qMenu = QtWidgets.QMenu(title)
3181
+
3182
+ self._commandHandlers = {
3183
+ 'addItem': self._addItem,
3184
+ 'addSeparator': self._addSeparator,
3185
+ 'addSubmenu': self._addSubmenu,
3186
+ 'enable': self._enable,
3187
+ 'disable': self._disable,
3188
+ }
3189
+
3190
+ def handleCommand(self, action, args, responseId):
3191
+ handler = self._commandHandlers.get(action)
3192
+ if handler is not None:
3193
+ handler(args, responseId)
3194
+
3195
+ def _addItem(self, args, responseId):
3196
+ text = args.get('text', '')
3197
+ itemIndex = args.get('itemIndex', 0)
3198
+ qAction = QtGui.QAction(text, self._qMenu)
3199
+ qAction.triggered.connect(
3200
+ lambda checked=False, idx=itemIndex: self._onItemTriggered(idx)
3201
+ )
3202
+ self._qMenu.addAction(qAction)
3203
+
3204
+ def _onItemTriggered(self, itemIndex):
3205
+ isRegistered = (self.objectId, 'itemTriggered') in self._guiRenderer._registeredEvents
3206
+ if isRegistered:
3207
+ self._guiRenderer.sendEvent('itemTriggered', self.objectId, {'itemIndex': itemIndex})
3208
+
3209
+ def _addSeparator(self, args, responseId):
3210
+ self._qMenu.addSeparator()
3211
+
3212
+ def _addSubmenu(self, args, responseId):
3213
+ submenuId = args.get('submenuId')
3214
+ submenu = self._guiRenderer._objectRegistry.get(submenuId)
3215
+ if submenu is not None:
3216
+ self._qMenu.addMenu(submenu._qMenu)
3217
+
3218
+ def _enable(self, args, responseId):
3219
+ self._qMenu.setEnabled(True)
3220
+
3221
+ def _disable(self, args, responseId):
3222
+ self._qMenu.setEnabled(False)