kitbash 1.0.1__py2.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.
- kitbash/__init__.py +113 -0
- kitbash/gui/__init__.py +92 -0
- kitbash/gui/main_window.py +1263 -0
- kitbash/gui/main_window.ui +433 -0
- kitbash/gui/samples_explorer.py +200 -0
- kitbash/gui/samples_explorer.ui +126 -0
- kitbash/res/group_expanded.svg +2 -0
- kitbash/res/group_hidden.svg +2 -0
- kitbash/res/kitbash-icon.png +0 -0
- kitbash/scripts/bash_project.py +97 -0
- kitbash/styles/light.css +135 -0
- kitbash/styles/system.css +65 -0
- kitbash-1.0.1.dist-info/METADATA +25 -0
- kitbash-1.0.1.dist-info/RECORD +17 -0
- kitbash-1.0.1.dist-info/WHEEL +5 -0
- kitbash-1.0.1.dist-info/entry_points.txt +4 -0
- kitbash-1.0.1.dist-info/licenses/LICENSE +619 -0
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
# kitbash/gui/main_window.py
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2025 liyang <liyang@veronica>
|
|
4
|
+
#
|
|
5
|
+
"""
|
|
6
|
+
Provides MainWindow class and
|
|
7
|
+
"""
|
|
8
|
+
import os, logging, json
|
|
9
|
+
from os.path import dirname, basename, realpath, exists, join, splitext
|
|
10
|
+
from collections import deque
|
|
11
|
+
from functools import partial
|
|
12
|
+
from signal import signal, SIGINT, SIGTERM
|
|
13
|
+
|
|
14
|
+
from PyQt5 import uic
|
|
15
|
+
from PyQt5.QtCore import Qt, QObject, pyqtSignal, pyqtSlot, QSize, QTimer, \
|
|
16
|
+
QThreadPool, QRunnable, QPoint, QCoreApplication
|
|
17
|
+
from PyQt5.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, \
|
|
18
|
+
QLabel, QFrame, QSizePolicy, QPushButton, QCheckBox, \
|
|
19
|
+
QMainWindow, QMessageBox, QFileDialog, \
|
|
20
|
+
QAction, QActionGroup, QMenu, \
|
|
21
|
+
QGroupBox, QRadioButton
|
|
22
|
+
from PyQt5.QtGui import QIcon
|
|
23
|
+
|
|
24
|
+
from qt_extras import ShutUpQT, SigBlock, DevilBox
|
|
25
|
+
from qt_extras.list_layout import VListLayout
|
|
26
|
+
from recent_items_list import RecentItemsList
|
|
27
|
+
from midi_notes import MIDI_DRUM_PITCHES
|
|
28
|
+
from liquiphy import LiquidSFZ
|
|
29
|
+
from conn_jack import JackConnectionManager
|
|
30
|
+
from jack_midi_split import MidiSplitter
|
|
31
|
+
from sfzen.drumkits import Drumkit, PercussionInstrument
|
|
32
|
+
from sfzen import SAMPLES_ABSPATH, SAMPLES_RESOLVE, SAMPLES_COPY, \
|
|
33
|
+
SAMPLES_SYMLINK, SAMPLES_HARDLINK
|
|
34
|
+
|
|
35
|
+
from kitbash.gui import group_expanded_icon, group_hidden_icon, remove_icon, \
|
|
36
|
+
GeometrySaver
|
|
37
|
+
from kitbash import styles, set_application_style, settings, \
|
|
38
|
+
APPLICATION_NAME, PACKAGE_DIR, \
|
|
39
|
+
KEY_STYLE, KEY_SAMPLES_MODE, \
|
|
40
|
+
KEY_RECENT_DRUMKIT_FOLDER, KEY_RECENT_DRUMKITS, \
|
|
41
|
+
KEY_RECENT_PROJECT_FOLDER, KEY_RECENT_PROJECTS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DrumkitWidget(QFrame):
|
|
45
|
+
"""
|
|
46
|
+
Graphical representation of a drumkit.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
sig_inst_toggle = pyqtSignal(QObject, str, bool, bool)
|
|
50
|
+
sig_remove_drumkit = pyqtSignal(QObject)
|
|
51
|
+
|
|
52
|
+
def __init__(self, filename, parent):
|
|
53
|
+
super().__init__(parent)
|
|
54
|
+
self.sfz_filename = filename
|
|
55
|
+
self.moniker = basename(self.sfz_filename)
|
|
56
|
+
self.drumkit = None
|
|
57
|
+
self.synth = None
|
|
58
|
+
self.port_number = None
|
|
59
|
+
self.initial_height = None
|
|
60
|
+
self.velocity = None
|
|
61
|
+
|
|
62
|
+
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred)
|
|
63
|
+
|
|
64
|
+
main_layout = QVBoxLayout()
|
|
65
|
+
main_layout.setContentsMargins(1,1,1,1)
|
|
66
|
+
main_layout.setSpacing(0)
|
|
67
|
+
|
|
68
|
+
frm_top = QFrame()
|
|
69
|
+
frm_top.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
70
|
+
frm_top.setObjectName('frm_top')
|
|
71
|
+
|
|
72
|
+
lo_top = QHBoxLayout()
|
|
73
|
+
lo_top.setContentsMargins(2,2,2,2)
|
|
74
|
+
lo_top.setSpacing(0)
|
|
75
|
+
|
|
76
|
+
self.hide_button = QPushButton(self)
|
|
77
|
+
self.hide_button.setIcon(group_expanded_icon())
|
|
78
|
+
self.hide_button.setIconSize(QSize(16,16))
|
|
79
|
+
self.hide_button.setCheckable(True)
|
|
80
|
+
self.hide_button.toggled.connect(self.slot_hide)
|
|
81
|
+
lo_top.addWidget(self.hide_button)
|
|
82
|
+
|
|
83
|
+
label = QLabel(self)
|
|
84
|
+
label.setText(self.sfz_filename)
|
|
85
|
+
lo_top.addWidget(label)
|
|
86
|
+
|
|
87
|
+
self.lbl_use_count = QLabel(self)
|
|
88
|
+
self.lbl_use_count.setText('(0)')
|
|
89
|
+
lo_top.addWidget(self.lbl_use_count)
|
|
90
|
+
|
|
91
|
+
lo_top.addStretch(20)
|
|
92
|
+
|
|
93
|
+
remove_button = QPushButton(self)
|
|
94
|
+
remove_button.setIcon(remove_icon())
|
|
95
|
+
remove_button.setIconSize(QSize(16,16))
|
|
96
|
+
remove_button.clicked.connect(self.slot_remove_clicked)
|
|
97
|
+
lo_top.addWidget(remove_button)
|
|
98
|
+
|
|
99
|
+
frm_top.setLayout(lo_top)
|
|
100
|
+
|
|
101
|
+
main_layout.addWidget(frm_top)
|
|
102
|
+
|
|
103
|
+
self.frm_groups = QFrame(self)
|
|
104
|
+
self.frm_groups.setObjectName('frm_groups')
|
|
105
|
+
self.groups = QHBoxLayout()
|
|
106
|
+
self.groups.setContentsMargins(1,1,1,1)
|
|
107
|
+
self.groups.setSpacing(0)
|
|
108
|
+
|
|
109
|
+
self.frm_groups.setLayout(self.groups)
|
|
110
|
+
main_layout.addWidget(self.frm_groups)
|
|
111
|
+
self.setLayout(main_layout)
|
|
112
|
+
|
|
113
|
+
def ready(self):
|
|
114
|
+
"""
|
|
115
|
+
Returns "True" if this DrumkitWidget has a synth assigned and a bashed kit.
|
|
116
|
+
"""
|
|
117
|
+
return not self.synth is None and not self.drumkit is None
|
|
118
|
+
|
|
119
|
+
@pyqtSlot(Drumkit)
|
|
120
|
+
def slot_drumkit_loaded(self, drumkit):
|
|
121
|
+
"""
|
|
122
|
+
Called when KitLoader is finshed loading and interpreted SFZ.
|
|
123
|
+
Fills the groups and instruments of this the DrumkitWidget.
|
|
124
|
+
"""
|
|
125
|
+
self.drumkit = drumkit
|
|
126
|
+
for group in self.drumkit.groups.values():
|
|
127
|
+
if group.empty():
|
|
128
|
+
continue
|
|
129
|
+
group_frame = GroupFrame(group, self)
|
|
130
|
+
group_frame.group_button.clicked.connect(partial(self.slot_group_clicked, group_frame))
|
|
131
|
+
for inst in group.instruments.values():
|
|
132
|
+
inst_button = InstrumentButton(inst, group_frame)
|
|
133
|
+
inst_button.toggled.connect(partial(self.slot_instrument_toggled, inst_button))
|
|
134
|
+
inst_button.sig_mouse_press.connect(self.slot_instrument_pressed)
|
|
135
|
+
inst_button.sig_mouse_release.connect(self.slot_instrument_released)
|
|
136
|
+
group_frame.group_layout.addWidget(inst_button)
|
|
137
|
+
group_frame.group_layout.addStretch()
|
|
138
|
+
self.groups.addWidget(group_frame)
|
|
139
|
+
self.groups.addStretch()
|
|
140
|
+
|
|
141
|
+
@pyqtSlot(QFrame)
|
|
142
|
+
def slot_group_clicked(self, group_frame):
|
|
143
|
+
"""
|
|
144
|
+
Triggered by a GroupButton click event.
|
|
145
|
+
"group_frame" is the QFrame which contains the clicked GroupButton and various
|
|
146
|
+
InstrumentButton instances.
|
|
147
|
+
InstrumentButton signals are not suppressed, and trigger "sig_inst_toggle".
|
|
148
|
+
"""
|
|
149
|
+
group_button = group_frame.findChild(GroupButton)
|
|
150
|
+
for inst_button in group_frame.findChildren(InstrumentButton):
|
|
151
|
+
inst_button.setChecked(group_button.isChecked())
|
|
152
|
+
|
|
153
|
+
@pyqtSlot(int)
|
|
154
|
+
def slot_velocity_change(self, velocity):
|
|
155
|
+
self.velocity = velocity
|
|
156
|
+
|
|
157
|
+
@pyqtSlot(PercussionInstrument)
|
|
158
|
+
def slot_instrument_pressed(self, inst):
|
|
159
|
+
"""
|
|
160
|
+
Triggered by InstrumentButton mouse press.
|
|
161
|
+
Sends a "noteon" to this widget's synth.
|
|
162
|
+
"""
|
|
163
|
+
self.synth.noteon(0, inst.pitch, self.velocity)
|
|
164
|
+
|
|
165
|
+
@pyqtSlot(PercussionInstrument)
|
|
166
|
+
def slot_instrument_released(self, inst):
|
|
167
|
+
"""
|
|
168
|
+
Triggered by InstrumentButton mouse relase.
|
|
169
|
+
Sends a "v" to this widget's synth.
|
|
170
|
+
"""
|
|
171
|
+
self.synth.noteoff(0, inst.pitch)
|
|
172
|
+
|
|
173
|
+
@pyqtSlot(QPushButton)
|
|
174
|
+
def slot_instrument_toggled(self, button):
|
|
175
|
+
"""
|
|
176
|
+
Triggered by an InstrumentButton toggle event.
|
|
177
|
+
"inst_id" is a string key, enumerated in the DrumkitClass.
|
|
178
|
+
"button" is the InstrumentButton which was toggled.
|
|
179
|
+
"""
|
|
180
|
+
self.sig_inst_toggle.emit(self, button.inst.inst_id,
|
|
181
|
+
button.isChecked(), self.ctrl_pressed())
|
|
182
|
+
self.update_count()
|
|
183
|
+
|
|
184
|
+
@pyqtSlot()
|
|
185
|
+
def slot_remove_clicked(self):
|
|
186
|
+
"""
|
|
187
|
+
Triggered by the "remove" button click event.
|
|
188
|
+
"""
|
|
189
|
+
self.sig_remove_drumkit.emit(self)
|
|
190
|
+
|
|
191
|
+
@pyqtSlot(bool)
|
|
192
|
+
def slot_hide(self, state):
|
|
193
|
+
"""
|
|
194
|
+
"Roll up" this DrumkitWidget.
|
|
195
|
+
"""
|
|
196
|
+
if state:
|
|
197
|
+
self.initial_height = self.height()
|
|
198
|
+
self.frm_groups.hide()
|
|
199
|
+
self.hide_button.setIcon(group_hidden_icon())
|
|
200
|
+
else:
|
|
201
|
+
self.frm_groups.show()
|
|
202
|
+
self.hide_button.setIcon(group_expanded_icon())
|
|
203
|
+
|
|
204
|
+
def update_count(self):
|
|
205
|
+
"""
|
|
206
|
+
Updates the "use count" label with the number of selected instruments.
|
|
207
|
+
Sets the audio indicator pixmap based on if playing or not.
|
|
208
|
+
"""
|
|
209
|
+
use_count = len([ b for b in self.frm_groups.findChildren(InstrumentButton) if b.isChecked() ])
|
|
210
|
+
self.lbl_use_count.setText('(%d)' % use_count)
|
|
211
|
+
font = self.lbl_use_count.font()
|
|
212
|
+
font.setBold(bool(use_count))
|
|
213
|
+
self.lbl_use_count.setFont(font)
|
|
214
|
+
|
|
215
|
+
def ctrl_pressed(self):
|
|
216
|
+
"""
|
|
217
|
+
Returns (bool) True if the CTRL key is being pressed. Useful for making
|
|
218
|
+
multiple selections.
|
|
219
|
+
"""
|
|
220
|
+
return QApplication.keyboardModifiers() == Qt.ControlModifier
|
|
221
|
+
|
|
222
|
+
def inst_button(self, inst_id):
|
|
223
|
+
"""
|
|
224
|
+
Returns the instrument button identified by the given inst_id.
|
|
225
|
+
"""
|
|
226
|
+
return self.findChild(InstrumentButton, inst_id)
|
|
227
|
+
|
|
228
|
+
def deselect_parent_group(self, inst_id):
|
|
229
|
+
"""
|
|
230
|
+
Called whenever an InstrumentButton is deselected.
|
|
231
|
+
The parent group button is deselected.
|
|
232
|
+
"""
|
|
233
|
+
inst_button = self.inst_button(inst_id)
|
|
234
|
+
inst_button.parentWidget().findChild(GroupButton).setChecked(False)
|
|
235
|
+
|
|
236
|
+
def reselect_parent_group(self, inst_id):
|
|
237
|
+
"""
|
|
238
|
+
Called whenever an InstrumentButton is selected.
|
|
239
|
+
The parent group button is selected if all of its' InstrumentButtons are
|
|
240
|
+
selected.
|
|
241
|
+
"""
|
|
242
|
+
group = self.inst_button(inst_id).parentWidget()
|
|
243
|
+
if all(inst_button.isChecked() for inst_button in group.findChildren(InstrumentButton)):
|
|
244
|
+
group.findChild(GroupButton).setChecked(True)
|
|
245
|
+
|
|
246
|
+
def deselect_instrument(self, inst_id):
|
|
247
|
+
"""
|
|
248
|
+
Called from MainWindow when a button with the same inst_id is selected
|
|
249
|
+
exclusively (not CTRL key pressed).
|
|
250
|
+
"""
|
|
251
|
+
button = self.inst_button(inst_id)
|
|
252
|
+
if button: # May not exist, as not all Drumkits use the same instruments
|
|
253
|
+
button.setChecked(False)
|
|
254
|
+
self.update_count()
|
|
255
|
+
|
|
256
|
+
def selected_instrument_ids(self):
|
|
257
|
+
"""
|
|
258
|
+
Returns a list of instrument ids from selected instrument buttons.
|
|
259
|
+
"""
|
|
260
|
+
return [ button.inst.inst_id \
|
|
261
|
+
for button in self.findChildren(InstrumentButton) \
|
|
262
|
+
if button.isChecked() ]
|
|
263
|
+
|
|
264
|
+
@pyqtSlot()
|
|
265
|
+
def slot_select_all(self):
|
|
266
|
+
"""
|
|
267
|
+
Select all instruments.
|
|
268
|
+
Triggered by kits_area context menu.
|
|
269
|
+
Called when this is the first DrumkitWidget added to a project.
|
|
270
|
+
"""
|
|
271
|
+
for type_ in [GroupButton, InstrumentButton]:
|
|
272
|
+
for button in self.findChildren(type_):
|
|
273
|
+
button.setChecked(True)
|
|
274
|
+
self.update_count()
|
|
275
|
+
|
|
276
|
+
def saved_selections(self):
|
|
277
|
+
"""
|
|
278
|
+
Returns dictionary of button states for saving with project.
|
|
279
|
+
"""
|
|
280
|
+
return {
|
|
281
|
+
group_frame.group_id : {
|
|
282
|
+
'group' : group_frame.findChild(GroupButton).isChecked(),
|
|
283
|
+
'instruments' : {
|
|
284
|
+
inst_button.inst.inst_id : inst_button.isChecked() \
|
|
285
|
+
for inst_button in group_frame.findChildren(InstrumentButton)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for group_frame in self.findChildren(GroupFrame)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def apply_selections(self, selections):
|
|
292
|
+
"""
|
|
293
|
+
Restores button states from dictionary when loading project.
|
|
294
|
+
"""
|
|
295
|
+
for group_frame in self.findChildren(GroupFrame):
|
|
296
|
+
if group_frame.group_id in selections:
|
|
297
|
+
sel = selections[group_frame.group_id]
|
|
298
|
+
group_button = group_frame.findChild(GroupButton)
|
|
299
|
+
with SigBlock(group_button):
|
|
300
|
+
group_button.setChecked(sel['group'])
|
|
301
|
+
for inst_button in group_frame.findChildren(InstrumentButton):
|
|
302
|
+
if inst_button.inst.inst_id in sel['instruments']:
|
|
303
|
+
inst_button.setChecked(sel['instruments'][inst_button.inst.inst_id])
|
|
304
|
+
else:
|
|
305
|
+
logging.warning('Button "%s" not found in project def', inst_button.inst.inst_id)
|
|
306
|
+
else:
|
|
307
|
+
logging.warning('Group "%s" not found in project def', group_frame.group_id)
|
|
308
|
+
|
|
309
|
+
def __str__(self):
|
|
310
|
+
return f"<DrumkitWidget {self.moniker}>"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class MainWindow(QMainWindow, GeometrySaver):
|
|
314
|
+
|
|
315
|
+
instance = None
|
|
316
|
+
options = None
|
|
317
|
+
sig_ports_complete = pyqtSignal()
|
|
318
|
+
|
|
319
|
+
def __new__(cls, options):
|
|
320
|
+
if cls.instance is None:
|
|
321
|
+
cls.instance = super().__new__(cls)
|
|
322
|
+
return cls.instance
|
|
323
|
+
|
|
324
|
+
def __init__(self, options):
|
|
325
|
+
if self.options:
|
|
326
|
+
return
|
|
327
|
+
super().__init__()
|
|
328
|
+
self.options = options
|
|
329
|
+
set_application_style()
|
|
330
|
+
with ShutUpQT():
|
|
331
|
+
uic.loadUi(join(PACKAGE_DIR, 'gui', 'main_window.ui'), self)
|
|
332
|
+
self.setWindowIcon(QIcon(join(PACKAGE_DIR, 'res', 'kitbash-icon.png')))
|
|
333
|
+
self.restore_geometry()
|
|
334
|
+
self.recent_projects = RecentItemsList(settings().value(KEY_RECENT_PROJECTS, []))
|
|
335
|
+
self.recent_drumkits = RecentItemsList(settings().value(KEY_RECENT_DRUMKITS, []))
|
|
336
|
+
self.dirty = False
|
|
337
|
+
self.project_filename = None
|
|
338
|
+
self.project_definition = None
|
|
339
|
+
self.project_loading = False
|
|
340
|
+
self.bashed_sfz_filename = None
|
|
341
|
+
self.bashed_sfz_samples_mode = None
|
|
342
|
+
self.drumkit_port_ranges = set( port_number for port_number in range(16) )
|
|
343
|
+
self.synth_creation_queue = deque()
|
|
344
|
+
self.new_synth = None
|
|
345
|
+
self.current_midi_source = None
|
|
346
|
+
self.current_audio_sink = None
|
|
347
|
+
self.audio_sink_ports = []
|
|
348
|
+
self.base_xruns = self.current_xruns = 0
|
|
349
|
+
self.b_xruns.setText('0')
|
|
350
|
+
self.fill_style_menu()
|
|
351
|
+
self.setup_window_elements()
|
|
352
|
+
self.connect_actions()
|
|
353
|
+
self.clear()
|
|
354
|
+
# Setup background threadpool for KitLoader and KitBasher workers
|
|
355
|
+
self.background_threadpool = QThreadPool()
|
|
356
|
+
# Setup connection manager and synth creation pool
|
|
357
|
+
self.conn_man = JackConnectionManager()
|
|
358
|
+
self.conn_man.on_error(self.jack_error)
|
|
359
|
+
self.conn_man.on_xrun(self.jack_xrun)
|
|
360
|
+
self.conn_man.on_shutdown(self.jack_shutdown)
|
|
361
|
+
self.conn_man.on_client_registration(self.jack_client_registration)
|
|
362
|
+
self.conn_man.on_port_registration(self.jack_port_registration)
|
|
363
|
+
# Fill sink/source menus:
|
|
364
|
+
self.fill_cmb_sources()
|
|
365
|
+
self.fill_cmb_sinks()
|
|
366
|
+
# Setup signals
|
|
367
|
+
self.sig_ports_complete.connect(self.slot_ports_complete)
|
|
368
|
+
self.cmb_midi_srcs.currentTextChanged.connect(self.slot_midi_src_changed)
|
|
369
|
+
self.cmb_audio_sinks.currentTextChanged.connect(self.slot_audio_sink_changed)
|
|
370
|
+
# Setup MidiSplitter
|
|
371
|
+
self.midi_splitter = MidiSplitter(APPLICATION_NAME)
|
|
372
|
+
self.splitter_assignments = [ None for i in range(16) ]
|
|
373
|
+
# Setup signals
|
|
374
|
+
signal(SIGINT, self.system_signal)
|
|
375
|
+
signal(SIGTERM, self.system_signal)
|
|
376
|
+
if self.options.Filename:
|
|
377
|
+
QTimer.singleShot(10, partial(self.load_project, self.options.Filename))
|
|
378
|
+
|
|
379
|
+
def setup_window_elements(self):
|
|
380
|
+
self.drumkit_widgets = VListLayout(end_space = 10)
|
|
381
|
+
self.drumkit_widgets.setContentsMargins(0,0,0,0)
|
|
382
|
+
self.drumkit_widgets.setSpacing(2)
|
|
383
|
+
self.kits_area.setLayout(self.drumkit_widgets)
|
|
384
|
+
|
|
385
|
+
def connect_actions(self):
|
|
386
|
+
self.action_collapse_kits.triggered.connect(self.slot_collapse_kits)
|
|
387
|
+
self.action_new_project.triggered.connect(self.slot_new_project)
|
|
388
|
+
self.action_open_project.triggered.connect(self.slot_open_project)
|
|
389
|
+
self.action_save_project.triggered.connect(self.slot_save_project)
|
|
390
|
+
self.action_save_project_as.triggered.connect(self.slot_save_project_as)
|
|
391
|
+
self.action_save_bashed_kit.triggered.connect(self.slot_save_kit)
|
|
392
|
+
self.action_save_kit_as.triggered.connect(self.slot_save_kit_as)
|
|
393
|
+
self.action_add_drumkit.triggered.connect(self.slot_add_drumkit)
|
|
394
|
+
self.action_remove_all_kits.triggered.connect(self.slot_remove_all_kits)
|
|
395
|
+
self.action_reload_style.triggered.connect(self.slot_reload_style)
|
|
396
|
+
self.menu_recent_project.aboutToShow.connect(self.slot_show_recent_projects)
|
|
397
|
+
self.menu_recent_drumkits.aboutToShow.connect(self.slot_show_recent_drumkits)
|
|
398
|
+
self.kits_area.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
399
|
+
self.kits_area.customContextMenuRequested.connect(self.slot_kits_context_menu)
|
|
400
|
+
self.b_copy_path.clicked.connect(self.slot_copy_kit_path)
|
|
401
|
+
self.b_xruns.clicked.connect(self.slot_xruns_clicked)
|
|
402
|
+
|
|
403
|
+
def update_ui(self):
|
|
404
|
+
title = APPLICATION_NAME \
|
|
405
|
+
if self.project_filename is None \
|
|
406
|
+
else f"{self.project_filename} [{APPLICATION_NAME}]"
|
|
407
|
+
self.setWindowTitle("* " + title if self.dirty else title)
|
|
408
|
+
has_kits = bool(len(self.drumkit_widgets))
|
|
409
|
+
self.action_collapse_kits.setEnabled(has_kits)
|
|
410
|
+
self.action_collapse_kits.setChecked(has_kits)
|
|
411
|
+
self.action_remove_all_kits.setEnabled(has_kits)
|
|
412
|
+
self.action_new_project.setEnabled(has_kits)
|
|
413
|
+
self.action_save_project.setEnabled(has_kits and self.dirty)
|
|
414
|
+
self.action_save_bashed_kit.setEnabled(has_kits)
|
|
415
|
+
self.b_save_kit.setEnabled(has_kits)
|
|
416
|
+
self.b_copy_path.setVisible(bool(self.bashed_sfz_filename))
|
|
417
|
+
self.lbl_bashed_sfz_filename.setText(self.bashed_sfz_filename \
|
|
418
|
+
if self.bashed_sfz_filename else '')
|
|
419
|
+
|
|
420
|
+
# -----------------------------------------------------------------
|
|
421
|
+
# Style functions:
|
|
422
|
+
|
|
423
|
+
def fill_style_menu(self):
|
|
424
|
+
"""
|
|
425
|
+
Fill the style menu with the list of discovered styles.
|
|
426
|
+
"""
|
|
427
|
+
current_style = settings().value(KEY_STYLE)
|
|
428
|
+
actions = QActionGroup(self)
|
|
429
|
+
actions.setExclusive(True)
|
|
430
|
+
for style_name in styles():
|
|
431
|
+
action = QAction(style_name, self)
|
|
432
|
+
action.triggered.connect(partial(self.select_style, style_name))
|
|
433
|
+
action.setCheckable(True)
|
|
434
|
+
action.setChecked(style_name == current_style)
|
|
435
|
+
actions.addAction(action)
|
|
436
|
+
self.menu_style.addAction(action)
|
|
437
|
+
|
|
438
|
+
def select_style(self, style):
|
|
439
|
+
settings().setValue(KEY_STYLE, style)
|
|
440
|
+
set_application_style()
|
|
441
|
+
|
|
442
|
+
@pyqtSlot()
|
|
443
|
+
def slot_reload_style(self):
|
|
444
|
+
set_application_style()
|
|
445
|
+
|
|
446
|
+
# -----------------------------------------------------------------
|
|
447
|
+
# Project loading / saving:
|
|
448
|
+
|
|
449
|
+
def set_dirty(self, state = True):
|
|
450
|
+
if not self.project_loading:
|
|
451
|
+
self.dirty = state
|
|
452
|
+
self.update_ui()
|
|
453
|
+
|
|
454
|
+
def compile_project_def(self):
|
|
455
|
+
return {
|
|
456
|
+
'bashed_sfz_filename' : self.bashed_sfz_filename,
|
|
457
|
+
'bashed_sfz_samples_mode' : self.bashed_sfz_samples_mode,
|
|
458
|
+
'drumkits' : {
|
|
459
|
+
widget.sfz_filename : widget.saved_selections() \
|
|
460
|
+
for widget in self.drumkit_widgets
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
def load_recent_project(self, filename):
|
|
465
|
+
if self.okay_to_clear():
|
|
466
|
+
self.load_project(filename)
|
|
467
|
+
|
|
468
|
+
def load_project(self, filename):
|
|
469
|
+
"""
|
|
470
|
+
Called internally - NOT FROM GUI SIGNALS.
|
|
471
|
+
Starts project load; saves recent file name.
|
|
472
|
+
Permission to clear must already have been given.
|
|
473
|
+
"""
|
|
474
|
+
if exists(filename):
|
|
475
|
+
try:
|
|
476
|
+
with open(filename, 'r', encoding = 'utf-8') as fh:
|
|
477
|
+
self.project_definition = json.load(fh)
|
|
478
|
+
except json.JSONDecodeError as e:
|
|
479
|
+
DevilBox('There was a problem decoding:\n' +
|
|
480
|
+
f'"{filename}"\n' + \
|
|
481
|
+
f'"{e}"\n' + \
|
|
482
|
+
'Are you sure it is a kitbash project?')
|
|
483
|
+
else:
|
|
484
|
+
if len(self.drumkit_widgets):
|
|
485
|
+
self.clear()
|
|
486
|
+
self.project_filename = realpath(filename)
|
|
487
|
+
self.register_recent_project()
|
|
488
|
+
self.project_loading = True
|
|
489
|
+
self.bashed_sfz_filename = self.project_definition['bashed_sfz_filename']
|
|
490
|
+
self.bashed_sfz_samples_mode = self.project_definition['bashed_sfz_samples_mode']
|
|
491
|
+
for sfzfile in self.project_definition['drumkits'].keys():
|
|
492
|
+
self.load_drumkit(sfzfile)
|
|
493
|
+
else:
|
|
494
|
+
self.recent_projects.remove(filename)
|
|
495
|
+
settings().setValue(KEY_RECENT_PROJECTS, self.recent_projects.items)
|
|
496
|
+
DevilBox(f"Project not found: {filename}")
|
|
497
|
+
|
|
498
|
+
def save_project(self):
|
|
499
|
+
with open(self.project_filename, 'w', encoding = 'utf-8') as fh:
|
|
500
|
+
json.dump(self.compile_project_def(), fh, indent="\t")
|
|
501
|
+
self.register_recent_project()
|
|
502
|
+
self.set_dirty(False)
|
|
503
|
+
|
|
504
|
+
def save_kit(self):
|
|
505
|
+
worker = KitBasher(self.drumkit_widgets)
|
|
506
|
+
worker.signals.sig_bashed.connect(self.slot_drumkit_bashed)
|
|
507
|
+
self.background_threadpool.start(worker)
|
|
508
|
+
|
|
509
|
+
def register_recent_project(self):
|
|
510
|
+
self.recent_projects.bump(self.project_filename)
|
|
511
|
+
settings().setValue(KEY_RECENT_PROJECT_FOLDER, dirname(self.project_filename))
|
|
512
|
+
settings().setValue(KEY_RECENT_PROJECTS, self.recent_projects.items)
|
|
513
|
+
|
|
514
|
+
def okay_to_clear(self):
|
|
515
|
+
if not self.dirty:
|
|
516
|
+
return True
|
|
517
|
+
dlg = QMessageBox(
|
|
518
|
+
QMessageBox.Warning,
|
|
519
|
+
"Save changes?",
|
|
520
|
+
"There are changes to the current project.\nDo you want to save changes?",
|
|
521
|
+
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
|
|
522
|
+
self
|
|
523
|
+
)
|
|
524
|
+
ret = dlg.exec()
|
|
525
|
+
if ret == QMessageBox.Cancel:
|
|
526
|
+
return False
|
|
527
|
+
if ret == QMessageBox.Save:
|
|
528
|
+
self.slot_save_project()
|
|
529
|
+
return True
|
|
530
|
+
|
|
531
|
+
def clear(self):
|
|
532
|
+
self.project_filename = None
|
|
533
|
+
self.project_definition = None
|
|
534
|
+
self.project_loading = False
|
|
535
|
+
self.bashed_kit = None
|
|
536
|
+
self.bashed_sfz_filename = None
|
|
537
|
+
self.bashed_sfz_samples_mode = None
|
|
538
|
+
self.new_synth = None
|
|
539
|
+
self.dirty = False
|
|
540
|
+
for widget in reversed(self.drumkit_widgets):
|
|
541
|
+
self.slot_remove_drumkit(widget)
|
|
542
|
+
self.set_dirty(False)
|
|
543
|
+
|
|
544
|
+
# -----------------------------------------------------------------
|
|
545
|
+
# Quit / close / signals
|
|
546
|
+
|
|
547
|
+
def closeEvent(self, event):
|
|
548
|
+
"""
|
|
549
|
+
PyQt closeEvent overload.
|
|
550
|
+
"""
|
|
551
|
+
if self.okay_to_clear():
|
|
552
|
+
for drumkit_widget in self.drumkit_widgets:
|
|
553
|
+
drumkit_widget.synth.quit()
|
|
554
|
+
self.save_geometry()
|
|
555
|
+
logging.debug('Total %d xruns', self.current_xruns)
|
|
556
|
+
event.accept()
|
|
557
|
+
else:
|
|
558
|
+
event.ignore()
|
|
559
|
+
|
|
560
|
+
def system_signal(self, *_):
|
|
561
|
+
"""
|
|
562
|
+
Catch system signals SIGINT and SIGTERM
|
|
563
|
+
"""
|
|
564
|
+
logging.debug('Caught signal - shutting down')
|
|
565
|
+
self.close()
|
|
566
|
+
|
|
567
|
+
# -----------------------------------------------------------------
|
|
568
|
+
# Source / sink combo boxes
|
|
569
|
+
|
|
570
|
+
def fill_cmb_sources(self):
|
|
571
|
+
with SigBlock(self.cmb_midi_srcs):
|
|
572
|
+
self.cmb_midi_srcs.clear()
|
|
573
|
+
self.cmb_midi_srcs.addItem('')
|
|
574
|
+
for port in self.conn_man.output_ports():
|
|
575
|
+
if port.is_midi and APPLICATION_NAME not in port.name:
|
|
576
|
+
self.cmb_midi_srcs.addItem(port.name)
|
|
577
|
+
if self.current_midi_source:
|
|
578
|
+
self.cmb_midi_srcs.setCurrentText(self.current_midi_source)
|
|
579
|
+
|
|
580
|
+
def fill_cmb_sinks(self):
|
|
581
|
+
with SigBlock(self.cmb_audio_sinks):
|
|
582
|
+
self.cmb_audio_sinks.clear()
|
|
583
|
+
items = ['']
|
|
584
|
+
items.extend(self.conn_man.physical_playback_clients())
|
|
585
|
+
self.cmb_audio_sinks.addItems(items)
|
|
586
|
+
if self.current_audio_sink:
|
|
587
|
+
self.cmb_audio_sinks.setCurrentText(self.current_audio_sink)
|
|
588
|
+
|
|
589
|
+
@pyqtSlot(str)
|
|
590
|
+
def slot_midi_src_changed(self, value):
|
|
591
|
+
if self.current_midi_source:
|
|
592
|
+
self.conn_man.disconnect_by_name(self.current_midi_source, self.midi_splitter.input_port.name)
|
|
593
|
+
self.current_midi_source = value
|
|
594
|
+
if self.current_midi_source:
|
|
595
|
+
self.conn_man.connect_by_name(self.current_midi_source, self.midi_splitter.input_port.name)
|
|
596
|
+
|
|
597
|
+
@pyqtSlot(str)
|
|
598
|
+
def slot_audio_sink_changed(self, value):
|
|
599
|
+
if self.current_audio_sink:
|
|
600
|
+
liquid_client_names = [
|
|
601
|
+
drumkit_widget.synth.client_name \
|
|
602
|
+
for drumkit_widget in self.drumkit_widgets \
|
|
603
|
+
if drumkit_widget.synth and drumkit_widget.synth.client_name
|
|
604
|
+
]
|
|
605
|
+
for audio_sink_port in self.audio_sink_ports:
|
|
606
|
+
for src_port in self.conn_man.get_port_connections(audio_sink_port):
|
|
607
|
+
if src_port.client_name in liquid_client_names:
|
|
608
|
+
self.conn_man.disconnect(src_port, audio_sink_port)
|
|
609
|
+
self.current_audio_sink = value
|
|
610
|
+
if self.current_audio_sink:
|
|
611
|
+
self.audio_sink_ports = [ port for port \
|
|
612
|
+
in self.conn_man.physical_input_ports() \
|
|
613
|
+
if port.client_name == self.current_audio_sink ]
|
|
614
|
+
for drumkit_widget in self.drumkit_widgets:
|
|
615
|
+
self.connect_audio_sink(drumkit_widget.synth)
|
|
616
|
+
else:
|
|
617
|
+
self.audio_sink_ports = []
|
|
618
|
+
|
|
619
|
+
def connect_midi_source(self, synth):
|
|
620
|
+
if self.current_midi_source:
|
|
621
|
+
self.conn_man.connect_by_name(self.current_midi_source, synth.input_port.name)
|
|
622
|
+
|
|
623
|
+
def connect_audio_sink(self, synth):
|
|
624
|
+
for src,tgt in zip(synth.output_ports, self.audio_sink_ports):
|
|
625
|
+
self.conn_man.connect(src, tgt)
|
|
626
|
+
|
|
627
|
+
# -----------------------------------------------------------------
|
|
628
|
+
# Synth / port management
|
|
629
|
+
|
|
630
|
+
def instantiate_synth(self, associated_object):
|
|
631
|
+
self.synth_creation_queue.append(associated_object)
|
|
632
|
+
if self.new_synth is None:
|
|
633
|
+
self.start_new_synth()
|
|
634
|
+
|
|
635
|
+
def start_new_synth(self):
|
|
636
|
+
self.new_synth = JackLiquidSFZ(self.synth_creation_queue[0].sfz_filename)
|
|
637
|
+
self.new_synth.start()
|
|
638
|
+
|
|
639
|
+
def jack_error(self, error_message):
|
|
640
|
+
logging.error('JACK ERROR: %s', error_message)
|
|
641
|
+
|
|
642
|
+
def jack_xrun(self, xruns):
|
|
643
|
+
self.b_xruns.setText(str(xruns - self.base_xruns))
|
|
644
|
+
self.current_xruns = xruns
|
|
645
|
+
|
|
646
|
+
def jack_shutdown(self):
|
|
647
|
+
logging.error('JACK is shutting down')
|
|
648
|
+
self.close()
|
|
649
|
+
|
|
650
|
+
def jack_client_registration(self, client_name, action):
|
|
651
|
+
if action:
|
|
652
|
+
if self.new_synth and 'liquidsfz' in client_name:
|
|
653
|
+
self.new_synth.client_name = client_name
|
|
654
|
+
else:
|
|
655
|
+
if self.cmb_audio_sinks.findText(client_name, Qt.MatchStartsWith) > -1:
|
|
656
|
+
self.fill_cmb_sinks()
|
|
657
|
+
elif self.cmb_midi_srcs.findText(client_name, Qt.MatchStartsWith) > -1:
|
|
658
|
+
self.fill_cmb_sources()
|
|
659
|
+
|
|
660
|
+
def jack_port_registration(self, port, action):
|
|
661
|
+
if action and \
|
|
662
|
+
self.new_synth and \
|
|
663
|
+
self.new_synth.client_name and \
|
|
664
|
+
self.new_synth.client_name in port.name:
|
|
665
|
+
if port.is_input and port.is_midi:
|
|
666
|
+
self.new_synth.input_port = port
|
|
667
|
+
elif port.is_output and port.is_audio:
|
|
668
|
+
self.new_synth.output_ports.append(port)
|
|
669
|
+
else:
|
|
670
|
+
logging.error('Incorrect port type: %s', port)
|
|
671
|
+
if self.new_synth.input_port and len(self.new_synth.output_ports) == 2:
|
|
672
|
+
self.sig_ports_complete.emit()
|
|
673
|
+
elif APPLICATION_NAME not in port.name:
|
|
674
|
+
if port.is_output and port.is_midi:
|
|
675
|
+
self.fill_cmb_sources()
|
|
676
|
+
elif port.is_input and port.is_audio:
|
|
677
|
+
self.fill_cmb_sinks()
|
|
678
|
+
|
|
679
|
+
@pyqtSlot()
|
|
680
|
+
def slot_ports_complete(self):
|
|
681
|
+
associated_object = self.synth_creation_queue.popleft()
|
|
682
|
+
associated_object.synth = self.new_synth
|
|
683
|
+
if len(self.synth_creation_queue):
|
|
684
|
+
self.start_new_synth()
|
|
685
|
+
else:
|
|
686
|
+
self.new_synth = None
|
|
687
|
+
self.connect_audio_sink(associated_object.synth)
|
|
688
|
+
if isinstance(associated_object, DrumkitWidget):
|
|
689
|
+
logging.debug('%s ports complete', associated_object)
|
|
690
|
+
src = self.midi_splitter.output_ports[associated_object.port_number].name
|
|
691
|
+
tgt = associated_object.synth.input_port.name
|
|
692
|
+
self.conn_man.connect_by_name(src, tgt)
|
|
693
|
+
self.check_drumkit_ready(associated_object)
|
|
694
|
+
else:
|
|
695
|
+
self.connect_midi_source(associated_object.synth)
|
|
696
|
+
|
|
697
|
+
# -----------------------------------------------------------------
|
|
698
|
+
# Drumkit load / delete / instrument selection
|
|
699
|
+
|
|
700
|
+
def load_drumkit(self, filename):
|
|
701
|
+
"""
|
|
702
|
+
Adds a drumkit.
|
|
703
|
+
1. called at project load
|
|
704
|
+
2. triggered by "Edit -> Load Drumkit" menu
|
|
705
|
+
3. triggered by kits_area custom context menu
|
|
706
|
+
"""
|
|
707
|
+
if exists(filename):
|
|
708
|
+
drumkit_widget = DrumkitWidget(filename, self)
|
|
709
|
+
available_ports = self.available_port_numbers()
|
|
710
|
+
if available_ports:
|
|
711
|
+
drumkit_widget.port_number = available_ports[0]
|
|
712
|
+
else:
|
|
713
|
+
DevilBox('Not enough ports (Maximum 16)')
|
|
714
|
+
self.drumkit_widgets.append(drumkit_widget)
|
|
715
|
+
QApplication.instance().processEvents()
|
|
716
|
+
self.instantiate_synth(drumkit_widget)
|
|
717
|
+
worker = KitLoader(drumkit_widget)
|
|
718
|
+
worker.signals.sig_loaded.connect(drumkit_widget.slot_drumkit_loaded)
|
|
719
|
+
worker.signals.sig_widget_loaded.connect(self.slot_drumkit_widget_loaded)
|
|
720
|
+
self.background_threadpool.start(worker)
|
|
721
|
+
if not self.project_loading:
|
|
722
|
+
self.recent_drumkits.bump(filename)
|
|
723
|
+
settings().setValue(KEY_RECENT_DRUMKIT_FOLDER, dirname(filename))
|
|
724
|
+
else:
|
|
725
|
+
self.recent_drumkits.remove(filename)
|
|
726
|
+
DevilBox(f"File not found: {filename}")
|
|
727
|
+
if not self.project_loading:
|
|
728
|
+
settings().setValue(KEY_RECENT_DRUMKITS, self.recent_drumkits.items)
|
|
729
|
+
|
|
730
|
+
@pyqtSlot(DrumkitWidget)
|
|
731
|
+
def slot_drumkit_widget_loaded(self, drumkit_widget):
|
|
732
|
+
"""
|
|
733
|
+
Called when KitLoader is finshed loading and interpreted SFZ.
|
|
734
|
+
"""
|
|
735
|
+
self.update_ui()
|
|
736
|
+
self.check_drumkit_ready(drumkit_widget)
|
|
737
|
+
|
|
738
|
+
def check_drumkit_ready(self, drumkit_widget):
|
|
739
|
+
"""
|
|
740
|
+
Check if any/all drumkit widget has a synth assigned and a bashed kit.
|
|
741
|
+
1. called after KitLoader is finished,
|
|
742
|
+
2. called after synth is assigned (ports ready)
|
|
743
|
+
If ready when project_loading, applies saved selections.
|
|
744
|
+
"""
|
|
745
|
+
if drumkit_widget.ready():
|
|
746
|
+
drumkit_widget.sig_inst_toggle.connect(self.slot_inst_toggle)
|
|
747
|
+
drumkit_widget.sig_remove_drumkit.connect(self.slot_remove_drumkit)
|
|
748
|
+
drumkit_widget.velocity = self.spn_velocity.value()
|
|
749
|
+
self.spn_velocity.valueChanged.connect(drumkit_widget.slot_velocity_change)
|
|
750
|
+
if self.project_loading:
|
|
751
|
+
drumkit_widget.apply_selections(
|
|
752
|
+
self.project_definition['drumkits'][drumkit_widget.sfz_filename])
|
|
753
|
+
if all(drumkit_widget.ready() for drumkit_widget in self.drumkit_widgets):
|
|
754
|
+
self.project_loading = False
|
|
755
|
+
self.update_ui()
|
|
756
|
+
else:
|
|
757
|
+
if len(self.drumkit_widgets) == 1:
|
|
758
|
+
drumkit_widget.slot_select_all()
|
|
759
|
+
self.set_dirty()
|
|
760
|
+
|
|
761
|
+
@pyqtSlot(QObject)
|
|
762
|
+
def slot_remove_drumkit(self, drumkit_widget):
|
|
763
|
+
"""
|
|
764
|
+
Directly triggered by kits_area custom context menu;
|
|
765
|
+
called in any place where a drumkit_widget needs to be removed,
|
|
766
|
+
including clear(), slot_remove_all_kits().
|
|
767
|
+
"""
|
|
768
|
+
self.midi_splitter.clear_port_assignments(drumkit_widget.port_number)
|
|
769
|
+
drumkit_widget.synth.quit()
|
|
770
|
+
self.drumkit_widgets.remove(drumkit_widget)
|
|
771
|
+
drumkit_widget.deleteLater()
|
|
772
|
+
self.set_dirty()
|
|
773
|
+
|
|
774
|
+
@pyqtSlot(QObject, str, bool, bool)
|
|
775
|
+
def slot_inst_toggle(self, source_widget, inst_id, state, ctrl_state):
|
|
776
|
+
"""
|
|
777
|
+
Triggered by DrumkitWidget InstrumentButton toggle event.
|
|
778
|
+
Parameters are:
|
|
779
|
+
"source_widget": DrumkitWidget containing the button clicked
|
|
780
|
+
"inst_id": (str) Identifies the button clicked
|
|
781
|
+
"state": (bool) True if "checked"
|
|
782
|
+
"ctrl_state": (bool) True if CTRL key pressed when clicking
|
|
783
|
+
"""
|
|
784
|
+
# Deselect all other InstrumentButton if not CTRL key pressed:
|
|
785
|
+
if state:
|
|
786
|
+
if not ctrl_state:
|
|
787
|
+
for drumkit_widget in self.drumkit_widgets:
|
|
788
|
+
if not drumkit_widget is source_widget:
|
|
789
|
+
drumkit_widget.deselect_instrument(inst_id)
|
|
790
|
+
source_widget.reselect_parent_group(inst_id)
|
|
791
|
+
# Deselect the GroupButton if instrument deselected:
|
|
792
|
+
else:
|
|
793
|
+
source_widget.deselect_parent_group(inst_id)
|
|
794
|
+
# Enable/disable routing midi events to the source_widget's synth:
|
|
795
|
+
if state:
|
|
796
|
+
self.midi_splitter.assign_note(
|
|
797
|
+
MIDI_DRUM_PITCHES[inst_id],
|
|
798
|
+
source_widget.port_number)
|
|
799
|
+
else:
|
|
800
|
+
self.midi_splitter.clear_note_assignment(
|
|
801
|
+
MIDI_DRUM_PITCHES[inst_id],
|
|
802
|
+
source_widget.port_number)
|
|
803
|
+
self.set_dirty()
|
|
804
|
+
|
|
805
|
+
@pyqtSlot(Drumkit)
|
|
806
|
+
def slot_drumkit_bashed(self, bashed_kit):
|
|
807
|
+
"""
|
|
808
|
+
Triggered from KitBasher signal when bashing is finished.
|
|
809
|
+
"""
|
|
810
|
+
try:
|
|
811
|
+
bashed_kit.save_as(self.bashed_sfz_filename, self.bashed_sfz_samples_mode)
|
|
812
|
+
self.lbl_bashed_sfz_filename.setText(self.bashed_sfz_filename)
|
|
813
|
+
logging.debug('Saved bashed .sfz at %s', self.bashed_sfz_filename)
|
|
814
|
+
except OSError as e:
|
|
815
|
+
DevilBox('Hardlinks between devices are not allowed.\n' +\
|
|
816
|
+
'Choose a different path or sample mode.' if e.errno == 18 \
|
|
817
|
+
else str(e))
|
|
818
|
+
|
|
819
|
+
def used_port_numbers(self):
|
|
820
|
+
"""
|
|
821
|
+
Returns a set of MidiSplitter port numbers assigned to drumkit widget's synth
|
|
822
|
+
"""
|
|
823
|
+
return set(drumkit_widget.port_number \
|
|
824
|
+
for drumkit_widget in self.drumkit_widgets)
|
|
825
|
+
|
|
826
|
+
def available_port_numbers(self):
|
|
827
|
+
"""
|
|
828
|
+
Returns a list of MidiSplitter port numbers not yet assigned to drumkit widget's synth
|
|
829
|
+
"""
|
|
830
|
+
return list(self.drumkit_port_ranges ^ self.used_port_numbers())
|
|
831
|
+
|
|
832
|
+
# -----------------------------------------------------------------
|
|
833
|
+
# UI handling slots:
|
|
834
|
+
|
|
835
|
+
@pyqtSlot()
|
|
836
|
+
def slot_xruns_clicked(self):
|
|
837
|
+
"""
|
|
838
|
+
Triggered by b_xruns.click()
|
|
839
|
+
"""
|
|
840
|
+
self.base_xruns = self.current_xruns
|
|
841
|
+
self.b_xruns.setText('0')
|
|
842
|
+
|
|
843
|
+
@pyqtSlot(QPoint)
|
|
844
|
+
def slot_kits_context_menu(self, position):
|
|
845
|
+
"""
|
|
846
|
+
Triggered by kits_area.customContextMenuRequested
|
|
847
|
+
"""
|
|
848
|
+
menu = QMenu()
|
|
849
|
+
clicked_drumkit_widget = self.kits_area.childAt(position)
|
|
850
|
+
if clicked_drumkit_widget is not None:
|
|
851
|
+
while not isinstance(clicked_drumkit_widget, DrumkitWidget) and \
|
|
852
|
+
clicked_drumkit_widget.parent() is not None:
|
|
853
|
+
clicked_drumkit_widget = clicked_drumkit_widget.parent()
|
|
854
|
+
if isinstance(clicked_drumkit_widget, DrumkitWidget):
|
|
855
|
+
action = QAction('Select all', self)
|
|
856
|
+
action.triggered.connect(clicked_drumkit_widget.slot_select_all)
|
|
857
|
+
menu.addAction(action)
|
|
858
|
+
action = QAction(f'Remove "{clicked_drumkit_widget.moniker}"', self)
|
|
859
|
+
action.triggered.connect(partial(self.slot_remove_drumkit, clicked_drumkit_widget))
|
|
860
|
+
menu.addAction(action)
|
|
861
|
+
menu.addAction(self.action_add_drumkit)
|
|
862
|
+
menu.addAction(self.action_remove_all_kits)
|
|
863
|
+
menu.addAction(self.action_collapse_kits)
|
|
864
|
+
menu.exec(self.kits_area.mapToGlobal(position))
|
|
865
|
+
|
|
866
|
+
@pyqtSlot()
|
|
867
|
+
def slot_remove_all_kits(self):
|
|
868
|
+
"""
|
|
869
|
+
Triggered by 'Edit -> Remove All Drumkits" menu and kits_area context menu.
|
|
870
|
+
"""
|
|
871
|
+
for drumkit_widget in reversed(self.drumkit_widgets):
|
|
872
|
+
self.slot_remove_drumkit(drumkit_widget)
|
|
873
|
+
|
|
874
|
+
@pyqtSlot()
|
|
875
|
+
def slot_collapse_kits(self):
|
|
876
|
+
"""
|
|
877
|
+
Triggered by "View -> Collapse Kits"
|
|
878
|
+
"""
|
|
879
|
+
for widget in self.drumkit_widgets:
|
|
880
|
+
widget.hide_button.setChecked(True)
|
|
881
|
+
|
|
882
|
+
@pyqtSlot()
|
|
883
|
+
def slot_show_recent_drumkits(self):
|
|
884
|
+
"""
|
|
885
|
+
Fills "recent_drumkits" menu before expanding
|
|
886
|
+
"""
|
|
887
|
+
self.menu_recent_drumkits.clear()
|
|
888
|
+
actions = []
|
|
889
|
+
for filename in self.recent_drumkits:
|
|
890
|
+
action = QAction(filename, self)
|
|
891
|
+
action.triggered.connect(partial(self.load_drumkit, filename))
|
|
892
|
+
actions.append(action)
|
|
893
|
+
self.menu_recent_drumkits.addActions(actions)
|
|
894
|
+
|
|
895
|
+
@pyqtSlot()
|
|
896
|
+
def slot_show_recent_projects(self):
|
|
897
|
+
"""
|
|
898
|
+
Fills "recent_projects" menu before expanding
|
|
899
|
+
"""
|
|
900
|
+
self.menu_recent_project.clear()
|
|
901
|
+
actions = []
|
|
902
|
+
for filename in self.recent_projects:
|
|
903
|
+
action = QAction(filename, self)
|
|
904
|
+
action.triggered.connect(partial(self.load_recent_project, filename))
|
|
905
|
+
actions.append(action)
|
|
906
|
+
self.menu_recent_project.addActions(actions)
|
|
907
|
+
|
|
908
|
+
@pyqtSlot()
|
|
909
|
+
def slot_new_project(self):
|
|
910
|
+
"""
|
|
911
|
+
Triggered by "File -> New"
|
|
912
|
+
"""
|
|
913
|
+
if self.okay_to_clear():
|
|
914
|
+
self.clear()
|
|
915
|
+
|
|
916
|
+
@pyqtSlot()
|
|
917
|
+
def slot_open_project(self):
|
|
918
|
+
"""
|
|
919
|
+
Triggered by "File -> Open Project"
|
|
920
|
+
"""
|
|
921
|
+
if self.okay_to_clear():
|
|
922
|
+
QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs, False)
|
|
923
|
+
filename = QFileDialog.getOpenFileName(self,
|
|
924
|
+
"Open saved project",
|
|
925
|
+
settings().value(KEY_RECENT_PROJECT_FOLDER, ""),
|
|
926
|
+
"Kitbash project (*.json)"
|
|
927
|
+
)[0]
|
|
928
|
+
if filename != '':
|
|
929
|
+
self.load_project(filename)
|
|
930
|
+
|
|
931
|
+
@pyqtSlot()
|
|
932
|
+
def slot_save_project(self):
|
|
933
|
+
"""
|
|
934
|
+
Triggered by "File -> Save Project"
|
|
935
|
+
Opens the file save dialog if project_filename is None; calls "save_project".
|
|
936
|
+
"""
|
|
937
|
+
if self.project_filename is None:
|
|
938
|
+
self.slot_save_project_as()
|
|
939
|
+
else:
|
|
940
|
+
self.save_project()
|
|
941
|
+
|
|
942
|
+
@pyqtSlot()
|
|
943
|
+
def slot_save_project_as(self):
|
|
944
|
+
"""
|
|
945
|
+
Triggered by "File -> Save Project As"
|
|
946
|
+
Opens the file save dialog, sets project_filename, calls "save_project".
|
|
947
|
+
"""
|
|
948
|
+
QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs, False)
|
|
949
|
+
filename, _ = QFileDialog.getSaveFileName(
|
|
950
|
+
self,
|
|
951
|
+
"Save Kitbash project ...",
|
|
952
|
+
settings().value(KEY_RECENT_PROJECT_FOLDER, os.getcwd() \
|
|
953
|
+
if self.project_filename is None \
|
|
954
|
+
else dirname(self.project_filename)),
|
|
955
|
+
"Kitbash project (*.json)"
|
|
956
|
+
)
|
|
957
|
+
if filename :
|
|
958
|
+
self.project_filename = realpath(
|
|
959
|
+
filename \
|
|
960
|
+
if splitext(filename)[-1].lower() == '.json' \
|
|
961
|
+
else filename + '.json')
|
|
962
|
+
self.save_project()
|
|
963
|
+
|
|
964
|
+
@pyqtSlot()
|
|
965
|
+
def slot_save_kit(self):
|
|
966
|
+
if self.bashed_sfz_filename is None:
|
|
967
|
+
self.slot_save_kit_as()
|
|
968
|
+
else:
|
|
969
|
+
self.save_kit()
|
|
970
|
+
|
|
971
|
+
@pyqtSlot()
|
|
972
|
+
def slot_save_kit_as(self):
|
|
973
|
+
"""
|
|
974
|
+
Triggered by "File -> Save bashed kit" menu
|
|
975
|
+
See also: slot_drumkit_bashed
|
|
976
|
+
"""
|
|
977
|
+
dlg = KitSaveDialog(self,
|
|
978
|
+
int(settings().value(KEY_SAMPLES_MODE, SAMPLES_ABSPATH)) \
|
|
979
|
+
if self.bashed_sfz_samples_mode is None \
|
|
980
|
+
else self.bashed_sfz_samples_mode)
|
|
981
|
+
if dlg.exec_() and dlg.selected_file:
|
|
982
|
+
self.bashed_sfz_filename = dlg.selected_file
|
|
983
|
+
self.bashed_sfz_samples_mode = dlg.samples_mode
|
|
984
|
+
self.set_dirty()
|
|
985
|
+
self.save_kit()
|
|
986
|
+
|
|
987
|
+
@pyqtSlot()
|
|
988
|
+
def slot_add_drumkit(self):
|
|
989
|
+
"""
|
|
990
|
+
Triggered by "Edit -> Add Drumkit" menu, and kits_area custom context menu..
|
|
991
|
+
"""
|
|
992
|
+
QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs, False)
|
|
993
|
+
filename = QFileDialog.getOpenFileName(self,
|
|
994
|
+
"Load Drumkit",
|
|
995
|
+
settings().value(KEY_RECENT_DRUMKIT_FOLDER, ''),
|
|
996
|
+
"SFZ file (*.sfz)"
|
|
997
|
+
)[0]
|
|
998
|
+
if filename != '':
|
|
999
|
+
self.load_drumkit(filename)
|
|
1000
|
+
|
|
1001
|
+
@pyqtSlot()
|
|
1002
|
+
def slot_copy_kit_path(self):
|
|
1003
|
+
QApplication.instance().clipboard().setText(self.lbl_bashed_sfz_filename.text())
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
class KitWorkerSignals(QObject):
|
|
1007
|
+
"""
|
|
1008
|
+
Signals common to KitLoader and KitBasher.
|
|
1009
|
+
(PyQt QRunnable does not support its own signals)
|
|
1010
|
+
"""
|
|
1011
|
+
sig_loaded = pyqtSignal(Drumkit)
|
|
1012
|
+
sig_widget_loaded = pyqtSignal(DrumkitWidget, Drumkit)
|
|
1013
|
+
sig_bashed = pyqtSignal(Drumkit)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
class KitLoader(QRunnable):
|
|
1017
|
+
"""
|
|
1018
|
+
Loads a drumkit in a background thread and emits "sig_loaded" when done.
|
|
1019
|
+
"""
|
|
1020
|
+
|
|
1021
|
+
def __init__(self, drumkit_widget):
|
|
1022
|
+
super().__init__()
|
|
1023
|
+
self.drumkit_widget = drumkit_widget
|
|
1024
|
+
self.signals = KitWorkerSignals()
|
|
1025
|
+
|
|
1026
|
+
@pyqtSlot()
|
|
1027
|
+
def run(self):
|
|
1028
|
+
drumkit = Drumkit(self.drumkit_widget.sfz_filename)
|
|
1029
|
+
self.signals.sig_loaded.emit(drumkit)
|
|
1030
|
+
self.signals.sig_widget_loaded.emit(self.drumkit_widget, drumkit)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
class KitBasher(QRunnable):
|
|
1034
|
+
"""
|
|
1035
|
+
Compiles a bashed kit and signals that its ready to be saved.
|
|
1036
|
+
"""
|
|
1037
|
+
|
|
1038
|
+
def __init__(self, drumkit_widgets):
|
|
1039
|
+
super().__init__()
|
|
1040
|
+
self.drumkit_widgets = drumkit_widgets
|
|
1041
|
+
self.signals = KitWorkerSignals()
|
|
1042
|
+
|
|
1043
|
+
@pyqtSlot()
|
|
1044
|
+
def run(self):
|
|
1045
|
+
bashed_kit = Drumkit()
|
|
1046
|
+
for drumkit_widget in self.drumkit_widgets:
|
|
1047
|
+
for inst_id in drumkit_widget.selected_instrument_ids():
|
|
1048
|
+
bashed_kit.import_instrument(inst_id, drumkit_widget.drumkit)
|
|
1049
|
+
self.signals.sig_bashed.emit(bashed_kit)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
class JackLiquidSFZ(LiquidSFZ):
|
|
1053
|
+
"""
|
|
1054
|
+
Wraps a LiquidSFZ instance in order to hold references to jacklib ports created
|
|
1055
|
+
by JackConnectionManager.
|
|
1056
|
+
"""
|
|
1057
|
+
|
|
1058
|
+
def __init__(self, filename):
|
|
1059
|
+
self.client_name = None
|
|
1060
|
+
self.input_port = None
|
|
1061
|
+
self.output_ports = []
|
|
1062
|
+
super().__init__(filename, defer_start = True)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
class KitSaveDialog(QFileDialog, GeometrySaver):
|
|
1066
|
+
"""
|
|
1067
|
+
Custom file dialog with added option for choosing samples_mode.
|
|
1068
|
+
"""
|
|
1069
|
+
|
|
1070
|
+
def __init__(self, parent, samples_mode):
|
|
1071
|
+
QCoreApplication.setAttribute(Qt.AA_DontUseNativeDialogs)
|
|
1072
|
+
super().__init__(parent)
|
|
1073
|
+
self.samples_mode = samples_mode
|
|
1074
|
+
self.restore_geometry()
|
|
1075
|
+
self.setWindowTitle("Save bashed kit as .sfz")
|
|
1076
|
+
self.setFileMode(QFileDialog.AnyFile)
|
|
1077
|
+
self.setViewMode(QFileDialog.List)
|
|
1078
|
+
lbl = QLabel()
|
|
1079
|
+
self.layout().addWidget(lbl)
|
|
1080
|
+
gb = QGroupBox('Sample location')
|
|
1081
|
+
self.r_abspath = QRadioButton('Point to the original samples - absolute path')
|
|
1082
|
+
self.r_resolve = QRadioButton('Point to the original samples - relative path')
|
|
1083
|
+
self.r_copy = QRadioButton('Copy samples to the "./samples" folder')
|
|
1084
|
+
self.r_symlink = QRadioButton('Create symlinks in the "./samples" folder')
|
|
1085
|
+
self.r_hardlink = QRadioButton('Hardlink the originals in the "./samples" folder')
|
|
1086
|
+
self.r_abspath.clicked.connect(partial(self.slot_set_mode, SAMPLES_ABSPATH))
|
|
1087
|
+
self.r_resolve.clicked.connect(partial(self.slot_set_mode, SAMPLES_RESOLVE))
|
|
1088
|
+
self.r_copy.clicked.connect(partial(self.slot_set_mode, SAMPLES_COPY))
|
|
1089
|
+
self.r_symlink.clicked.connect(partial(self.slot_set_mode, SAMPLES_SYMLINK))
|
|
1090
|
+
self.r_hardlink.clicked.connect(partial(self.slot_set_mode, SAMPLES_HARDLINK))
|
|
1091
|
+
lo = QVBoxLayout()
|
|
1092
|
+
lo.setContentsMargins(2,2,2,2)
|
|
1093
|
+
lo.setSpacing(2)
|
|
1094
|
+
lo.addWidget(self.r_abspath)
|
|
1095
|
+
lo.addWidget(self.r_resolve)
|
|
1096
|
+
lo.addWidget(self.r_copy)
|
|
1097
|
+
lo.addWidget(self.r_symlink)
|
|
1098
|
+
lo.addWidget(self.r_hardlink)
|
|
1099
|
+
gb.setLayout(lo)
|
|
1100
|
+
if self.samples_mode == SAMPLES_ABSPATH:
|
|
1101
|
+
self.r_abspath.setChecked(True)
|
|
1102
|
+
elif self.samples_mode == SAMPLES_RESOLVE:
|
|
1103
|
+
self.r_resolve.setChecked(True)
|
|
1104
|
+
elif self.samples_mode == SAMPLES_COPY:
|
|
1105
|
+
self.r_copy.setChecked(True)
|
|
1106
|
+
elif self.samples_mode == SAMPLES_SYMLINK:
|
|
1107
|
+
self.r_symlink.setChecked(True)
|
|
1108
|
+
else:
|
|
1109
|
+
self.r_hardlink.setChecked(True)
|
|
1110
|
+
self.layout().addWidget(gb)
|
|
1111
|
+
self.selected_file = None
|
|
1112
|
+
|
|
1113
|
+
@pyqtSlot(int, bool)
|
|
1114
|
+
def slot_set_mode(self, mode, _):
|
|
1115
|
+
"""
|
|
1116
|
+
Tiggered by any sample mode selection radio button.
|
|
1117
|
+
"""
|
|
1118
|
+
self.samples_mode = mode
|
|
1119
|
+
|
|
1120
|
+
@pyqtSlot()
|
|
1121
|
+
def accept(self):
|
|
1122
|
+
"""
|
|
1123
|
+
Overloaded function saves preferred mode, sets "selected_file".
|
|
1124
|
+
"""
|
|
1125
|
+
settings().setValue(KEY_SAMPLES_MODE, self.samples_mode)
|
|
1126
|
+
selected_files = self.selectedFiles()
|
|
1127
|
+
if selected_files:
|
|
1128
|
+
self.selected_file = realpath(
|
|
1129
|
+
selected_files[0] \
|
|
1130
|
+
if splitext(selected_files[0])[-1].lower() == '.sfz' \
|
|
1131
|
+
else selected_files[0] + '.sfz')
|
|
1132
|
+
else:
|
|
1133
|
+
self.selected_file = None
|
|
1134
|
+
super().accept()
|
|
1135
|
+
|
|
1136
|
+
def done(self, result):
|
|
1137
|
+
"""
|
|
1138
|
+
Overloaded function saves geometry.
|
|
1139
|
+
"""
|
|
1140
|
+
self.save_geometry()
|
|
1141
|
+
super().done(result)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
class GroupFrame(QFrame):
|
|
1145
|
+
"""
|
|
1146
|
+
QFrame which contains one GroupButton and one or more InstrumentButton
|
|
1147
|
+
"""
|
|
1148
|
+
|
|
1149
|
+
def __init__(self, group, parent):
|
|
1150
|
+
super().__init__(parent)
|
|
1151
|
+
self.setFrameShape(QFrame.NoFrame)
|
|
1152
|
+
self.setObjectName(group.group_id) # GroupFrame identified by group_id
|
|
1153
|
+
self.group_id = group.group_id
|
|
1154
|
+
self.group_layout = QVBoxLayout()
|
|
1155
|
+
self.group_layout.setSpacing(0)
|
|
1156
|
+
self.group_layout.setContentsMargins(0,0,0,0)
|
|
1157
|
+
self.setLayout(self.group_layout)
|
|
1158
|
+
self.group_button = GroupButton(self) # GroupButton has no unique object name
|
|
1159
|
+
self.group_button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
|
1160
|
+
self.group_button.setText(group.name)
|
|
1161
|
+
self.group_button.setCheckable(True)
|
|
1162
|
+
self.group_layout.addWidget(self.group_button)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
class GroupButton(QPushButton):
|
|
1166
|
+
"""
|
|
1167
|
+
Defined here to provide a distinct .css class name.
|
|
1168
|
+
"""
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
class InstrumentButton(QPushButton):
|
|
1172
|
+
"""
|
|
1173
|
+
Custom button with a contained InstrumentLabel and QCheckBox.
|
|
1174
|
+
The InstrumentLabel traps mouse press events, while the QCheckBox mirrors the
|
|
1175
|
+
"checked" state of this QPushButton.
|
|
1176
|
+
"""
|
|
1177
|
+
|
|
1178
|
+
sig_mouse_press = pyqtSignal(PercussionInstrument)
|
|
1179
|
+
sig_mouse_release = pyqtSignal(PercussionInstrument)
|
|
1180
|
+
|
|
1181
|
+
def __init__(self, inst, parent):
|
|
1182
|
+
super().__init__(parent)
|
|
1183
|
+
self.inst = inst
|
|
1184
|
+
self.setObjectName(inst.inst_id)
|
|
1185
|
+
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
|
1186
|
+
lo = QHBoxLayout()
|
|
1187
|
+
lo.setContentsMargins(0,0,0,0)
|
|
1188
|
+
lo.setSpacing(0)
|
|
1189
|
+
self.setLayout(lo)
|
|
1190
|
+
self.setCheckable(True)
|
|
1191
|
+
lo.addWidget(InstrumentLabel(inst, self))
|
|
1192
|
+
lo.addStretch()
|
|
1193
|
+
self.checkbox = QCheckBox(self)
|
|
1194
|
+
self.checkbox.stateChanged.connect(self.slot_checkbox_state_change)
|
|
1195
|
+
lo.addWidget(self.checkbox)
|
|
1196
|
+
|
|
1197
|
+
@pyqtSlot(int)
|
|
1198
|
+
def slot_checkbox_state_change(self, state):
|
|
1199
|
+
"""
|
|
1200
|
+
Triggered when contained checkbox is clicked.
|
|
1201
|
+
"""
|
|
1202
|
+
self.setChecked(state == Qt.Checked)
|
|
1203
|
+
|
|
1204
|
+
def checkStateSet(self):
|
|
1205
|
+
"""
|
|
1206
|
+
Extends QAbstractButton.checkStateSet.
|
|
1207
|
+
This is called in response to the gui setting the "checked" property of this QPushButton.
|
|
1208
|
+
"""
|
|
1209
|
+
with SigBlock(self.checkbox):
|
|
1210
|
+
self.checkbox.setChecked(self.isChecked())
|
|
1211
|
+
|
|
1212
|
+
def mousePressEvent(self, event):
|
|
1213
|
+
"""
|
|
1214
|
+
Overrides mouse so that only the contained checkbox will toggle this widget's state.
|
|
1215
|
+
"""
|
|
1216
|
+
event.accept()
|
|
1217
|
+
self.mouse_press()
|
|
1218
|
+
|
|
1219
|
+
def mouseReleaseEvent(self, event):
|
|
1220
|
+
"""
|
|
1221
|
+
Overrides mouse so that only the contained checkbox will toggle this widget's state.
|
|
1222
|
+
"""
|
|
1223
|
+
event.accept()
|
|
1224
|
+
self.mouse_release()
|
|
1225
|
+
|
|
1226
|
+
def mouse_press(self):
|
|
1227
|
+
"""
|
|
1228
|
+
Called from contained label. Sets the "down" state of this widget,
|
|
1229
|
+
(which is identified in the CSS as the ":pressed" pseudo-selector).
|
|
1230
|
+
"""
|
|
1231
|
+
self.setDown(True)
|
|
1232
|
+
self.sig_mouse_press.emit(self.inst)
|
|
1233
|
+
|
|
1234
|
+
def mouse_release(self):
|
|
1235
|
+
"""
|
|
1236
|
+
Called from contained label. Unsets the "down" state of this widget,
|
|
1237
|
+
(which is identified in the CSS as the ":pressed" pseudo-selector).
|
|
1238
|
+
"""
|
|
1239
|
+
self.setDown(False)
|
|
1240
|
+
self.sig_mouse_release.emit(self.inst)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
class InstrumentLabel(QLabel):
|
|
1244
|
+
"""
|
|
1245
|
+
Label contained inside an InstrumentButton, delegating its' mouse press /
|
|
1246
|
+
release events to its' parent.
|
|
1247
|
+
"""
|
|
1248
|
+
|
|
1249
|
+
def __init__(self, inst, parent):
|
|
1250
|
+
super().__init__(parent)
|
|
1251
|
+
self.setText(inst.name)
|
|
1252
|
+
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred)
|
|
1253
|
+
|
|
1254
|
+
def mousePressEvent(self, event):
|
|
1255
|
+
self.parent().mouse_press()
|
|
1256
|
+
event.accept()
|
|
1257
|
+
|
|
1258
|
+
def mouseReleaseEvent(self, event):
|
|
1259
|
+
self.parent().mouse_release()
|
|
1260
|
+
event.accept()
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
# end kitbash/gui/main_window.py
|