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.
- CreativePython/GuiHandler.py +518 -0
- CreativePython/GuiRenderer.py +3222 -0
- {creativepython-1.0.0.dist-info → creativepython-1.1.0.dist-info}/METADATA +2 -1
- {creativepython-1.0.0.dist-info → creativepython-1.1.0.dist-info}/RECORD +13 -11
- {creativepython-1.0.0.dist-info → creativepython-1.1.0.dist-info}/WHEEL +1 -1
- gui.py +2423 -2994
- image.py +20 -78
- midi.py +5 -5
- music.py +20 -20
- timer.py +32 -171
- {creativepython-1.0.0.dist-info → creativepython-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {creativepython-1.0.0.dist-info → creativepython-1.1.0.dist-info}/licenses/LICENSE-PSF +0 -0
- {creativepython-1.0.0.dist-info → creativepython-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -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)
|