1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Parameter SpinBox, a custom Qt widget
5# Copyright (C) 2011-2019 Filipe Coelho <falktx@falktx.com>
6#
7# This program is free software; you can redistribute it and/or
8# modify it under the terms of the GNU General Public License as
9# published by the Free Software Foundation; either version 2 of
10# the License, or any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# For a full copy of the GNU General Public License see the doc/GPL.txt file.
18
19# ------------------------------------------------------------------------------------------------------------
20# Imports (Global)
21
22from math import isnan, modf
23from random import random
24
25from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QTimer
26from PyQt5.QtGui import QCursor, QPalette
27from PyQt5.QtWidgets import QAbstractSpinBox, QApplication, QComboBox, QDialog, QMenu, QProgressBar
28
29# ------------------------------------------------------------------------------------------------------------
30# Imports (Custom)
31
32import ui_inputdialog_value
33
34from carla_shared import countDecimalPoints, MACOS
35
36# ------------------------------------------------------------------------------------------------------------
37# Get a fixed value within min/max bounds
38
39def geFixedValue(name, value, minimum, maximum):
40    if isnan(value):
41        print("Parameter '%s' is NaN! - %f" % (name, value))
42        return minimum
43    if value < minimum:
44        print("Parameter '%s' too low! - %f/%f" % (name, value, minimum))
45        return minimum
46    if value > maximum:
47        print("Parameter '%s' too high! - %f/%f" % (name, value, maximum))
48        return maximum
49    return value
50
51# ------------------------------------------------------------------------------------------------------------
52# Custom InputDialog with Scale Points support
53
54class CustomInputDialog(QDialog):
55    def __init__(self, parent, label, current, minimum, maximum, step, stepSmall, scalePoints, prefix, suffix):
56        QDialog.__init__(self, parent)
57        self.ui = ui_inputdialog_value.Ui_Dialog()
58        self.ui.setupUi(self)
59
60        decimals = countDecimalPoints(step, stepSmall)
61        self.ui.label.setText(label)
62        self.ui.doubleSpinBox.setDecimals(decimals)
63        self.ui.doubleSpinBox.setRange(minimum, maximum)
64        self.ui.doubleSpinBox.setSingleStep(step)
65        self.ui.doubleSpinBox.setValue(current)
66        self.ui.doubleSpinBox.setPrefix(prefix)
67        self.ui.doubleSpinBox.setSuffix(suffix)
68
69        if MACOS:
70            self.setWindowModality(Qt.WindowModal)
71
72        if not scalePoints:
73            self.ui.groupBox.setVisible(False)
74            self.resize(200, 0)
75        else:
76            text = "<table>"
77            for scalePoint in scalePoints:
78                valuestr = ("%i" if decimals == 0 else "%f") % scalePoint['value']
79                text += "<tr><td align='right'>%s</td><td align='left'> - %s</td></tr>" % (valuestr, scalePoint['label'])
80            text += "</table>"
81            self.ui.textBrowser.setText(text)
82            self.resize(200, 300)
83
84        self.fRetValue = current
85        self.adjustSize()
86        self.accepted.connect(self.slot_setReturnValue)
87
88    def returnValue(self):
89        return self.fRetValue
90
91    @pyqtSlot()
92    def slot_setReturnValue(self):
93        self.fRetValue = self.ui.doubleSpinBox.value()
94
95    def done(self, r):
96        QDialog.done(self, r)
97        self.close()
98
99# ------------------------------------------------------------------------------------------------------------
100# ProgressBar used for ParamSpinBox
101
102class ParamProgressBar(QProgressBar):
103    # signals
104    dragStateChanged = pyqtSignal(bool)
105    valueChanged = pyqtSignal(float)
106
107    def __init__(self, parent):
108        QProgressBar.__init__(self, parent)
109
110        self.fLeftClickDown = False
111        self.fIsInteger     = False
112        self.fIsReadOnly    = False
113
114        self.fMinimum   = 0.0
115        self.fMaximum   = 1.0
116        self.fInitiated = False
117        self.fRealValue = 0.0
118
119        self.fLastPaintedValue   = None
120        self.fCurrentPaintedText = ""
121
122        self.fName  = ""
123        self.fLabelPrefix = ""
124        self.fLabelSuffix = ""
125        self.fTextCall  = None
126        self.fValueCall = None
127
128        self.setFormat("(none)")
129
130        # Fake internal value, 10'000 precision
131        QProgressBar.setMinimum(self, 0)
132        QProgressBar.setMaximum(self, 10000)
133        QProgressBar.setValue(self, 0)
134
135    def setMinimum(self, value):
136        self.fMinimum = value
137
138    def setMaximum(self, value):
139        self.fMaximum = value
140
141    def setValue(self, value):
142        if (self.fRealValue == value or isnan(value)) and self.fInitiated:
143            return False
144
145        self.fInitiated = True
146        self.fRealValue = value
147        div = float(self.fMaximum - self.fMinimum)
148
149        if div == 0.0:
150            print("Parameter '%s' division by 0 prevented (value:%f, min:%f, max:%f)" % (self.fName,
151                                                                                         value,
152                                                                                         self.fMaximum,
153                                                                                         self.fMinimum))
154            vper = 1.0
155        elif isnan(value):
156            print("Parameter '%s' is NaN (value:%f, min:%f, max:%f)" % (self.fName,
157                                                                        value,
158                                                                        self.fMaximum,
159                                                                        self.fMinimum))
160            vper = 1.0
161        else:
162            vper = float(value - self.fMinimum) / div
163
164            if vper < 0.0:
165                vper = 0.0
166            elif vper > 1.0:
167                vper = 1.0
168
169        if self.fValueCall is not None:
170            self.fValueCall(value)
171
172        QProgressBar.setValue(self, int(vper * 10000))
173        return True
174
175    def setSuffixes(self, prefix, suffix):
176        self.fLabelPrefix = prefix
177        self.fLabelSuffix = suffix
178
179        # force refresh of text value
180        self.fLastPaintedValue = None
181
182        self.update()
183
184    def setName(self, name):
185        self.fName = name
186
187    def setReadOnly(self, yesNo):
188        self.fIsReadOnly = yesNo
189
190    def setTextCall(self, textCall):
191        self.fTextCall = textCall
192
193    def setValueCall(self, valueCall):
194        self.fValueCall = valueCall
195
196    def handleMouseEventPos(self, pos):
197        if self.fIsReadOnly:
198            return
199
200        xper  = float(pos.x()) / float(self.width())
201        value = xper * (self.fMaximum - self.fMinimum) + self.fMinimum
202
203        if self.fIsInteger:
204            value = round(value)
205
206        if value < self.fMinimum:
207            value = self.fMinimum
208        elif value > self.fMaximum:
209            value = self.fMaximum
210
211        if self.setValue(value):
212            self.valueChanged.emit(value)
213
214    def mousePressEvent(self, event):
215        if self.fIsReadOnly:
216            return
217
218        if event.button() == Qt.LeftButton:
219            self.handleMouseEventPos(event.pos())
220            self.fLeftClickDown = True
221            self.dragStateChanged.emit(True)
222        else:
223            self.fLeftClickDown = False
224
225        QProgressBar.mousePressEvent(self, event)
226
227    def mouseMoveEvent(self, event):
228        if self.fIsReadOnly:
229            return
230
231        if self.fLeftClickDown:
232            self.handleMouseEventPos(event.pos())
233
234        QProgressBar.mouseMoveEvent(self, event)
235
236    def mouseReleaseEvent(self, event):
237        if self.fIsReadOnly:
238            return
239
240        self.fLeftClickDown = False
241        self.dragStateChanged.emit(False)
242        QProgressBar.mouseReleaseEvent(self, event)
243
244    def paintEvent(self, event):
245        if self.fTextCall is not None:
246            if self.fLastPaintedValue != self.fRealValue:
247                self.fLastPaintedValue   = self.fRealValue
248                self.fCurrentPaintedText = self.fTextCall()
249            self.setFormat("%s%s%s" % (self.fLabelPrefix, self.fCurrentPaintedText, self.fLabelSuffix))
250
251        elif self.fIsInteger:
252            self.setFormat("%s%i%s" % (self.fLabelPrefix, int(self.fRealValue), self.fLabelSuffix))
253
254        else:
255            self.setFormat("%s%f%s" % (self.fLabelPrefix, self.fRealValue, self.fLabelSuffix))
256
257        QProgressBar.paintEvent(self, event)
258
259# ------------------------------------------------------------------------------------------------------------
260# Special SpinBox used for parameters
261
262class ParamSpinBox(QAbstractSpinBox):
263    # signals
264    valueChanged = pyqtSignal(float)
265
266    def __init__(self, parent):
267        QAbstractSpinBox.__init__(self, parent)
268
269        self.fName = ""
270        self.fLabelPrefix = ""
271        self.fLabelSuffix = ""
272
273        self.fMinimum = 0.0
274        self.fMaximum = 1.0
275        self.fDefault = 0.0
276        self.fValue   = None
277
278        self.fStep      = 0.01
279        self.fStepSmall = 0.0001
280        self.fStepLarge = 0.1
281
282        self.fIsReadOnly = False
283        self.fScalePoints = None
284        self.fUseScalePoints = False
285
286        self.fBar = ParamProgressBar(self)
287        self.fBar.setContextMenuPolicy(Qt.NoContextMenu)
288        #self.fBar.show()
289
290        barPalette = self.fBar.palette()
291        barPalette.setColor(QPalette.Window, Qt.transparent)
292        self.fBar.setPalette(barPalette)
293
294        self.fBox = None
295
296        self.lineEdit().hide()
297
298        self.customContextMenuRequested.connect(self.slot_showCustomMenu)
299        self.fBar.valueChanged.connect(self.slot_progressBarValueChanged)
300
301        self.dragStateChanged = self.fBar.dragStateChanged
302
303        QTimer.singleShot(0, self.slot_updateProgressBarGeometry)
304
305    def setDefault(self, value):
306        value = geFixedValue(self.fName, value, self.fMinimum, self.fMaximum)
307        self.fDefault = value
308
309    def setMinimum(self, value):
310        self.fMinimum = value
311        self.fBar.setMinimum(value)
312
313    def setMaximum(self, value):
314        self.fMaximum = value
315        self.fBar.setMaximum(value)
316
317    def setValue(self, value):
318        if not self.fIsReadOnly:
319            value = geFixedValue(self.fName, value, self.fMinimum, self.fMaximum)
320
321        if self.fBar.fIsInteger:
322            value = round(value)
323
324        if self.fValue == value:
325            return False
326
327        self.fValue = value
328        self.fBar.setValue(value)
329
330        if self.fUseScalePoints:
331            self._setScalePointValue(value)
332
333        self.valueChanged.emit(value)
334        self.update()
335
336        return True
337
338    def setStep(self, value):
339        if value == 0.0:
340            self.fStep = 0.001
341        else:
342            self.fStep = value
343
344        if self.fStepSmall > value:
345            self.fStepSmall = value
346        if self.fStepLarge < value:
347            self.fStepLarge = value
348
349        self.fBar.fIsInteger = bool(self.fStepSmall == 1.0)
350
351    def setStepSmall(self, value):
352        if value == 0.0:
353            self.fStepSmall = 0.0001
354        elif value > self.fStep:
355            self.fStepSmall = self.fStep
356        else:
357            self.fStepSmall = value
358
359        self.fBar.fIsInteger = bool(self.fStepSmall == 1.0)
360
361    def setStepLarge(self, value):
362        if value == 0.0:
363            self.fStepLarge = 0.1
364        elif value < self.fStep:
365            self.fStepLarge = self.fStep
366        else:
367            self.fStepLarge = value
368
369    def setLabel(self, label):
370        prefix = ""
371        suffix = label.strip()
372
373        if suffix == "(coef)":
374            prefix = "* "
375            suffix = ""
376        else:
377            suffix = " " + suffix
378
379        self.fLabelPrefix = prefix
380        self.fLabelSuffix = suffix
381        self.fBar.setSuffixes(prefix, suffix)
382
383    def setName(self, name):
384        self.fName = name
385        self.fBar.setName(name)
386
387    def setTextCallback(self, textCall):
388        self.fBar.setTextCall(textCall)
389
390    def setValueCallback(self, valueCall):
391        self.fBar.setValueCall(valueCall)
392
393    def setReadOnly(self, yesNo):
394        self.fIsReadOnly = yesNo
395        self.fBar.setReadOnly(yesNo)
396        self.setButtonSymbols(QAbstractSpinBox.UpDownArrows if yesNo else QAbstractSpinBox.NoButtons)
397        QAbstractSpinBox.setReadOnly(self, yesNo)
398
399    # FIXME use change event
400    def setEnabled(self, yesNo):
401        self.fBar.setEnabled(yesNo)
402        QAbstractSpinBox.setEnabled(self, yesNo)
403
404    def setScalePoints(self, scalePoints, useScalePoints):
405        if len(scalePoints) == 0:
406            self.fScalePoints    = None
407            self.fUseScalePoints = False
408            return
409
410        self.fScalePoints    = scalePoints
411        self.fUseScalePoints = useScalePoints
412
413        if not useScalePoints:
414            return
415
416        # Hide ProgressBar and create a ComboBox
417        self.fBar.close()
418        self.fBox = QComboBox(self)
419        self.fBox.setContextMenuPolicy(Qt.NoContextMenu)
420        #self.fBox.show()
421        self.slot_updateProgressBarGeometry()
422
423        # Add items, sorted
424        boxItemValues = []
425
426        for scalePoint in scalePoints:
427            value = scalePoint['value']
428
429            if self.fStep == 1.0:
430                label = "%i - %s" % (int(value), scalePoint['label'])
431            else:
432                label = "%f - %s" % (value, scalePoint['label'])
433
434            if len(boxItemValues) == 0:
435                self.fBox.addItem(label)
436                boxItemValues.append(value)
437
438            else:
439                if value < boxItemValues[0]:
440                    self.fBox.insertItem(0, label)
441                    boxItemValues.insert(0, value)
442                elif value > boxItemValues[-1]:
443                    self.fBox.addItem(label)
444                    boxItemValues.append(value)
445                else:
446                    for index in range(len(boxItemValues)):
447                        if value >= boxItemValues[index]:
448                            self.fBox.insertItem(index+1, label)
449                            boxItemValues.insert(index+1, value)
450                            break
451
452        if self.fValue is not None:
453            self._setScalePointValue(self.fValue)
454
455        self.fBox.currentIndexChanged['QString'].connect(self.slot_comboBoxIndexChanged)
456
457    def setToolTip(self, text):
458        self.fBar.setToolTip(text)
459        QAbstractSpinBox.setToolTip(self, text)
460
461    def stepBy(self, steps):
462        if steps == 0 or self.fValue is None:
463            return
464
465        value = self.fValue + (self.fStep * steps)
466
467        if value < self.fMinimum:
468            value = self.fMinimum
469        elif value > self.fMaximum:
470            value = self.fMaximum
471
472        self.setValue(value)
473
474    def stepEnabled(self):
475        if self.fIsReadOnly or self.fValue is None:
476            return QAbstractSpinBox.StepNone
477        if self.fValue <= self.fMinimum:
478            return QAbstractSpinBox.StepUpEnabled
479        if self.fValue >= self.fMaximum:
480            return QAbstractSpinBox.StepDownEnabled
481        return (QAbstractSpinBox.StepUpEnabled | QAbstractSpinBox.StepDownEnabled)
482
483    def updateAll(self):
484        self.update()
485        self.fBar.update()
486        if self.fBox is not None:
487            self.fBox.update()
488
489    def resizeEvent(self, event):
490        QAbstractSpinBox.resizeEvent(self, event)
491        self.slot_updateProgressBarGeometry()
492
493    @pyqtSlot(str)
494    def slot_comboBoxIndexChanged(self, boxText):
495        if self.fIsReadOnly:
496            return
497
498        value          = float(boxText.split(" - ", 1)[0])
499        lastScaleValue = self.fScalePoints[-1]['value']
500
501        if value == lastScaleValue:
502            value = self.fMaximum
503
504        self.setValue(value)
505
506    @pyqtSlot(float)
507    def slot_progressBarValueChanged(self, value):
508        if self.fIsReadOnly:
509            return
510
511        if value <= self.fMinimum:
512            realValue = self.fMinimum
513        elif value >= self.fMaximum:
514            realValue = self.fMaximum
515        else:
516            curStep   = int((value - self.fMinimum) / self.fStep + 0.5)
517            realValue = self.fMinimum + (self.fStep * curStep)
518
519            if realValue < self.fMinimum:
520                realValue = self.fMinimum
521            elif realValue > self.fMaximum:
522                realValue = self.fMaximum
523
524        self.setValue(realValue)
525
526    @pyqtSlot()
527    def slot_showCustomMenu(self):
528        clipboard  = QApplication.instance().clipboard()
529        pasteText  = clipboard.text()
530        pasteValue = None
531
532        if pasteText:
533            try:
534                pasteValue = float(pasteText)
535            except:
536                pass
537
538        menu      = QMenu(self)
539        actReset  = menu.addAction(self.tr("Reset (%f)" % self.fDefault))
540        actRandom = menu.addAction(self.tr("Random"))
541        menu.addSeparator()
542        actCopy   = menu.addAction(self.tr("Copy (%f)" % self.fValue))
543
544        if pasteValue is None:
545            actPaste = menu.addAction(self.tr("Paste"))
546            actPaste.setEnabled(False)
547        else:
548            actPaste = menu.addAction(self.tr("Paste (%f)" % pasteValue))
549
550        menu.addSeparator()
551
552        actSet = menu.addAction(self.tr("Set value..."))
553
554        if self.fIsReadOnly:
555            actReset.setEnabled(False)
556            actRandom.setEnabled(False)
557            actPaste.setEnabled(False)
558            actSet.setEnabled(False)
559
560        actSel = menu.exec_(QCursor.pos())
561
562        if actSel == actReset:
563            self.setValue(self.fDefault)
564
565        elif actSel == actRandom:
566            value = random() * (self.fMaximum - self.fMinimum) + self.fMinimum
567            self.setValue(value)
568
569        elif actSel == actCopy:
570            clipboard.setText("%f" % self.fValue)
571
572        elif actSel == actPaste:
573            self.setValue(pasteValue)
574
575        elif actSel == actSet:
576            dialog = CustomInputDialog(self, self.fName, self.fValue, self.fMinimum, self.fMaximum,
577                                             self.fStep, self.fStepSmall, self.fScalePoints,
578                                             self.fLabelPrefix, self.fLabelSuffix)
579            if dialog.exec_():
580                value = dialog.returnValue()
581                self.setValue(value)
582
583    @pyqtSlot()
584    def slot_updateProgressBarGeometry(self):
585        geometry = self.lineEdit().geometry()
586        dx = geometry.x()-1
587        dy = geometry.y()-1
588        geometry.adjust(-dx, -dy, dx, dy)
589        self.fBar.setGeometry(geometry)
590        if self.fUseScalePoints:
591            self.fBox.setGeometry(geometry)
592
593    def _getNearestScalePoint(self, realValue):
594        finalValue = 0.0
595
596        for i in range(len(self.fScalePoints)):
597            scaleValue = self.fScalePoints[i]["value"]
598            if i == 0:
599                finalValue = scaleValue
600            else:
601                srange1 = abs(realValue - scaleValue)
602                srange2 = abs(realValue - finalValue)
603
604                if srange2 > srange1:
605                    finalValue = scaleValue
606
607        return finalValue
608
609    def _setScalePointValue(self, value):
610        value = self._getNearestScalePoint(value)
611
612        for i in range(self.fBox.count()):
613            if float(self.fBox.itemText(i).split(" - ", 1)[0]) == value:
614                self.fBox.setCurrentIndex(i)
615                break
616