-
-
Notifications
You must be signed in to change notification settings - Fork 41
/
Copy pathqt.py
451 lines (365 loc) · 14.8 KB
/
qt.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
"""
Support for rendering in a Qt widget. Provides a widget subclass that
can be used as a standalone window or in a larger GUI.
"""
import sys
import ctypes
import importlib
from .base import WgpuCanvasBase, WgpuAutoGui
from ._gui_utils import (
get_alt_x11_display,
get_alt_wayland_display,
weakbind,
get_imported_qt_lib,
)
is_wayland = False # We force Qt to use X11 in _gui_utils.py
# Select GUI toolkit
libname, already_had_app_on_import = get_imported_qt_lib()
if libname:
QtCore = importlib.import_module(".QtCore", libname)
QtWidgets = importlib.import_module(".QtWidgets", libname)
try:
WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen
WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose
PreciseTimer = QtCore.Qt.TimerType.PreciseTimer
KeyboardModifiers = QtCore.Qt.KeyboardModifier
FocusPolicy = QtCore.Qt.FocusPolicy
Keys = QtCore.Qt.Key
except AttributeError:
WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen
WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose
PreciseTimer = QtCore.Qt.PreciseTimer
KeyboardModifiers = QtCore.Qt
FocusPolicy = QtCore.Qt
Keys = QtCore.Qt
else:
raise ImportError(
"Before importing wgpu.gui.qt, import one of PySide6/PySide2/PyQt6/PyQt5 to select a Qt toolkit."
)
# Get version
if libname.startswith("PySide"):
qt_version_info = QtCore.__version_info__
else:
try:
qt_version_info = tuple(int(i) for i in QtCore.QT_VERSION_STR.split(".")[:3])
except Exception: # Failsafe
qt_version_info = (0, 0, 0)
BUTTON_MAP = {
QtCore.Qt.MouseButton.LeftButton: 1, # == MOUSE_BUTTON_LEFT
QtCore.Qt.MouseButton.RightButton: 2, # == MOUSE_BUTTON_RIGHT
QtCore.Qt.MouseButton.MiddleButton: 3, # == MOUSE_BUTTON_MIDDLE
QtCore.Qt.MouseButton.BackButton: 4,
QtCore.Qt.MouseButton.ForwardButton: 5,
QtCore.Qt.MouseButton.TaskButton: 6,
QtCore.Qt.MouseButton.ExtraButton4: 7,
QtCore.Qt.MouseButton.ExtraButton5: 8,
}
MODIFIERS_MAP = {
KeyboardModifiers.ShiftModifier: "Shift",
KeyboardModifiers.ControlModifier: "Control",
KeyboardModifiers.AltModifier: "Alt",
KeyboardModifiers.MetaModifier: "Meta",
}
KEY_MAP = {
int(Keys.Key_Down): "ArrowDown",
int(Keys.Key_Up): "ArrowUp",
int(Keys.Key_Left): "ArrowLeft",
int(Keys.Key_Right): "ArrowRight",
int(Keys.Key_Backspace): "Backspace",
int(Keys.Key_CapsLock): "CapsLock",
int(Keys.Key_Delete): "Delete",
int(Keys.Key_End): "End",
int(Keys.Key_Enter): "Enter",
int(Keys.Key_Escape): "Escape",
int(Keys.Key_F1): "F1",
int(Keys.Key_F2): "F2",
int(Keys.Key_F3): "F3",
int(Keys.Key_F4): "F4",
int(Keys.Key_F5): "F5",
int(Keys.Key_F6): "F6",
int(Keys.Key_F7): "F7",
int(Keys.Key_F8): "F8",
int(Keys.Key_F9): "F9",
int(Keys.Key_F10): "F10",
int(Keys.Key_F11): "F11",
int(Keys.Key_F12): "F12",
int(Keys.Key_Home): "Home",
int(Keys.Key_Insert): "Insert",
int(Keys.Key_Alt): "Alt",
int(Keys.Key_Control): "Control",
int(Keys.Key_Shift): "Shift",
int(Keys.Key_Meta): "Meta", # meta maps to control in QT on macOS, and vice-versa
int(Keys.Key_NumLock): "NumLock",
int(Keys.Key_PageDown): "PageDown",
int(Keys.Key_PageUp): "Pageup",
int(Keys.Key_Pause): "Pause",
int(Keys.Key_ScrollLock): "ScrollLock",
int(Keys.Key_Tab): "Tab",
}
def enable_hidpi():
"""Enable high-res displays."""
set_dpi_aware = qt_version_info < (6, 4) # Pyside
if set_dpi_aware:
try:
# See https://github.com/pyzo/pyzo/pull/700 why we seem to need both
# See https://github.com/pygfx/pygfx/issues/368 for high Qt versions
ctypes.windll.shcore.SetProcessDpiAwareness(1) # global dpi aware
ctypes.windll.shcore.SetProcessDpiAwareness(2) # per-monitor dpi aware
except Exception:
pass # fail on non-windows
try:
QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)
except Exception:
pass # fail on older Qt's
# If you import this module, you want to use wgpu in a way that does not suck
# on high-res monitors. So we apply the minimal configuration to make this so.
# Most apps probably should also set AA_UseHighDpiPixmaps, but it's not
# needed for wgpu, so not our responsibility (some users may NOT want it set).
enable_hidpi()
class QWgpuWidget(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget):
"""A QWidget representing a wgpu canvas that can be embedded in a Qt application."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Configure how Qt renders this widget
self.setAttribute(WA_PaintOnScreen, True)
self.setAttribute(WA_DeleteOnClose, True)
self.setAutoFillBackground(False)
self.setMouseTracking(True)
self.setFocusPolicy(FocusPolicy.StrongFocus)
# A timer for limiting fps
self._request_draw_timer = QtCore.QTimer()
self._request_draw_timer.setTimerType(PreciseTimer)
self._request_draw_timer.setSingleShot(True)
self._request_draw_timer.timeout.connect(self.update)
def paintEngine(self): # noqa: N802 - this is a Qt method
# https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum WA_PaintOnScreen
return None
def paintEvent(self, event): # noqa: N802 - this is a Qt method
self._draw_frame_and_present()
# Methods that we add from wgpu (snake_case)
def get_surface_info(self):
if sys.platform.startswith("win") or sys.platform.startswith("darwin"):
return {
"window": int(self.winId()),
}
elif sys.platform.startswith("linux"):
# The trick to use an al display pointer works for X11, but results in a segfault on Wayland ...
if is_wayland:
return {
"platform": "wayland",
"window": int(self.winId()),
"display": int(get_alt_wayland_display()),
}
else:
return {
"platform": "x11",
"window": int(self.winId()),
"display": int(get_alt_x11_display()),
}
else:
raise RuntimeError(f"Cannot get Qt surafce info on {sys.platform}.")
def get_pixel_ratio(self):
# Observations:
# * On Win10 + PyQt5 the ratio is a whole number (175% becomes 2).
# * On Win10 + PyQt6 the ratio is correct (non-integer).
return self.devicePixelRatioF()
def get_logical_size(self):
# Sizes in Qt are logical
lsize = self.width(), self.height()
return float(lsize[0]), float(lsize[1])
def get_physical_size(self):
# https://doc.qt.io/qt-5/qpaintdevice.html
# https://doc.qt.io/qt-5/highdpi.html
lsize = self.width(), self.height()
lsize = float(lsize[0]), float(lsize[1])
ratio = self.devicePixelRatioF()
# When the ratio is not integer (qt6), we need to somehow round
# it. It turns out that we need to round it, but also add a
# small offset. Tested on Win10 with several different OS
# scales. Would be nice if we could ask Qt for the exact
# physical size! Not an issue on qt5, because ratio is always
# integer then.
return round(lsize[0] * ratio + 0.01), round(lsize[1] * ratio + 0.01)
def set_logical_size(self, width, height):
if width < 0 or height < 0:
raise ValueError("Window width and height must not be negative")
self.resize(width, height) # See comment on pixel ratio
def set_title(self, title):
self.setWindowTitle(title)
def _request_draw(self):
if not self._request_draw_timer.isActive():
self._request_draw_timer.start(int(self._get_draw_wait_time() * 1000))
def close(self):
QtWidgets.QWidget.close(self)
def is_closed(self):
return not self.isVisible()
# User events to jupyter_rfb events
def _key_event(self, event_type, event):
modifiers = tuple(
MODIFIERS_MAP[mod]
for mod in MODIFIERS_MAP.keys()
if mod & event.modifiers()
)
ev = {
"event_type": event_type,
"key": KEY_MAP.get(event.key(), event.text()),
"modifiers": modifiers,
}
self._handle_event_and_flush(ev)
def keyPressEvent(self, event): # noqa: N802
self._key_event("key_down", event)
def keyReleaseEvent(self, event): # noqa: N802
self._key_event("key_up", event)
def _mouse_event(self, event_type, event, touches=True):
button = BUTTON_MAP.get(event.button(), 0)
buttons = tuple(
BUTTON_MAP[button]
for button in BUTTON_MAP.keys()
if button & event.buttons()
)
# For Qt on macOS Control and Meta are switched
modifiers = tuple(
MODIFIERS_MAP[mod]
for mod in MODIFIERS_MAP.keys()
if mod & event.modifiers()
)
ev = {
"event_type": event_type,
"x": event.pos().x(),
"y": event.pos().y(),
"button": button,
"buttons": buttons,
"modifiers": modifiers,
}
if touches:
ev.update(
{
"ntouches": 0,
"touches": {}, # TODO: Qt touch events
}
)
if event_type == "pointer_move":
match_keys = {"buttons", "modifiers", "ntouches"}
accum_keys = {}
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)
else:
self._handle_event_and_flush(ev)
def mousePressEvent(self, event): # noqa: N802
self._mouse_event("pointer_down", event)
def mouseMoveEvent(self, event): # noqa: N802
self._mouse_event("pointer_move", event)
def mouseReleaseEvent(self, event): # noqa: N802
self._mouse_event("pointer_up", event)
def mouseDoubleClickEvent(self, event): # noqa: N802
super().mouseDoubleClickEvent(event)
self._mouse_event("double_click", event, touches=False)
def wheelEvent(self, event): # noqa: N802
# For Qt on macOS Control and Meta are switched
modifiers = tuple(
MODIFIERS_MAP[mod]
for mod in MODIFIERS_MAP.keys()
if mod & event.modifiers()
)
buttons = tuple(
BUTTON_MAP[button]
for button in BUTTON_MAP.keys()
if button & event.buttons()
)
ev = {
"event_type": "wheel",
"dx": -event.angleDelta().x(),
"dy": -event.angleDelta().y(),
"x": event.position().x(),
"y": event.position().y(),
"buttons": buttons,
"modifiers": modifiers,
}
match_keys = {"modifiers"}
accum_keys = {"dx", "dy"}
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)
def resizeEvent(self, event): # noqa: N802
ev = {
"event_type": "resize",
"width": float(event.size().width()),
"height": float(event.size().height()),
"pixel_ratio": self.get_pixel_ratio(),
}
self._handle_event_and_flush(ev)
def closeEvent(self, event): # noqa: N802
self._handle_event_and_flush({"event_type": "close"})
class QWgpuCanvas(WgpuAutoGui, WgpuCanvasBase, QtWidgets.QWidget):
"""A toplevel Qt widget providing a wgpu canvas."""
# Most of this is proxying stuff to the inner widget.
# We cannot use a toplevel widget directly, otherwise the window
# size can be set to subpixel (logical) values, without being able to
# detect this. See https://github.com/pygfx/wgpu-py/pull/68
def __init__(self, *, size=None, title=None, max_fps=30, **kwargs):
# When using Qt, there needs to be an
# application before any widget is created
get_app()
super().__init__(**kwargs)
self.setAttribute(WA_DeleteOnClose, True)
self.set_logical_size(*(size or (640, 480)))
self.setWindowTitle(title or "qt wgpu canvas")
self.setMouseTracking(True)
self._subwidget = QWgpuWidget(self, max_fps=max_fps)
self._subwidget.add_event_handler(weakbind(self.handle_event), "*")
# Note: At some point we called `self._subwidget.winId()` here. For some
# reason this was needed to "activate" the canvas. Otherwise the viz was
# not shown if no canvas was provided to request_adapter(). Removed
# later because could not reproduce.
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
layout.addWidget(self._subwidget)
self.show()
# Qt methods
def update(self):
super().update()
self._subwidget.update()
# Methods that we add from wgpu (snake_case)
@property
def draw_frame(self):
return self._subwidget.draw_frame
@draw_frame.setter
def draw_frame(self, f):
self._subwidget.draw_frame = f
def get_surface_info(self):
return self._subwidget.get_surface_info()
def get_pixel_ratio(self):
return self._subwidget.get_pixel_ratio()
def get_logical_size(self):
return self._subwidget.get_logical_size()
def get_physical_size(self):
return self._subwidget.get_physical_size()
def set_logical_size(self, width, height):
if width < 0 or height < 0:
raise ValueError("Window width and height must not be negative")
self.resize(width, height) # See comment on pixel ratio
def set_title(self, title):
self.setWindowTitle(title)
def _request_draw(self):
return self._subwidget._request_draw()
def close(self):
self._subwidget.close()
QtWidgets.QWidget.close(self)
def is_closed(self):
return not self.isVisible()
# Methods that we need to explicitly delegate to the subwidget
def get_context(self, *args, **kwargs):
return self._subwidget.get_context(*args, **kwargs)
def request_draw(self, *args, **kwargs):
return self._subwidget.request_draw(*args, **kwargs)
# Make available under a name that is the same for all gui backends
WgpuWidget = QWgpuWidget
WgpuCanvas = QWgpuCanvas
def get_app():
"""Return global instance of Qt app instance or create one if not created yet."""
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
def run():
if already_had_app_on_import:
return # Likely in an interactive session or larger application that will start the Qt app.
app = get_app()
app.exec() if hasattr(app, "exec") else app.exec_()
def call_later(delay, callback, *args):
QtCore.QTimer.singleShot(int(delay * 1000), lambda: callback(*args))