|  | #!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Pixmap Keyboard, a custom Qt widget
# Copyright (C) 2011-2022 Filipe Coelho <falktx@falktx.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation; either version 2 of
# the License, or any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# For a full copy of the GNU General Public License see the doc/GPL.txt file.
# ---------------------------------------------------------------------------------------------------------------------
# Imports (Global)
from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QPointF, QRectF, QTimer, QSize
from PyQt5.QtGui import QColor, QPainter, QPixmap
from PyQt5.QtWidgets import QActionGroup, QMenu, QScrollArea, QWidget
# ---------------------------------------------------------------------------------------------------------------------
# Imports (Custom)
from carla_shared import QSafeSettings
# ---------------------------------------------------------------------------------------------------------------------
kMidiKey2RectMapHorizontal = [
    QRectF(0,   0, 24, 57), # C
    QRectF(14,  0, 15, 33), # C#
    QRectF(24,  0, 24, 57), # D
    QRectF(42,  0, 15, 33), # D#
    QRectF(48,  0, 24, 57), # E
    QRectF(72,  0, 24, 57), # F
    QRectF(84,  0, 15, 33), # F#
    QRectF(96,  0, 24, 57), # G
    QRectF(112, 0, 15, 33), # G#
    QRectF(120, 0, 24, 57), # A
    QRectF(140, 0, 15, 33), # A#
    QRectF(144, 0, 24, 57), # B
]
kMidiKey2RectMapVertical = [
    QRectF(0, 144, 57, 24), # C
    QRectF(0, 139, 33, 15), # C#
    QRectF(0, 120, 57, 24), # D
    QRectF(0, 111, 33, 15), # D#
    QRectF(0, 96,  57, 24), # E
    QRectF(0, 72,  57, 24), # F
    QRectF(0, 69,  33, 15), # F#
    QRectF(0, 48,  57, 24), # G
    QRectF(0, 41,  33, 15), # G#
    QRectF(0, 24,  57, 24), # A
    QRectF(0, 13,  33, 15), # A#
    QRectF(0,  0,  57, 24), # B
]
kPcKeys_qwerty = [
    # 1st octave
    str(Qt.Key_Z),
    str(Qt.Key_S),
    str(Qt.Key_X),
    str(Qt.Key_D),
    str(Qt.Key_C),
    str(Qt.Key_V),
    str(Qt.Key_G),
    str(Qt.Key_B),
    str(Qt.Key_H),
    str(Qt.Key_N),
    str(Qt.Key_J),
    str(Qt.Key_M),
    # 2nd octave
    str(Qt.Key_Q),
    str(Qt.Key_2),
    str(Qt.Key_W),
    str(Qt.Key_3),
    str(Qt.Key_E),
    str(Qt.Key_R),
    str(Qt.Key_5),
    str(Qt.Key_T),
    str(Qt.Key_6),
    str(Qt.Key_Y),
    str(Qt.Key_7),
    str(Qt.Key_U),
    # 3rd octave
    str(Qt.Key_I),
    str(Qt.Key_9),
    str(Qt.Key_O),
    str(Qt.Key_0),
    str(Qt.Key_P),
]
kPcKeys_qwertz = [
    # 1st octave
    str(Qt.Key_Y),
    str(Qt.Key_S),
    str(Qt.Key_X),
    str(Qt.Key_D),
    str(Qt.Key_C),
    str(Qt.Key_V),
    str(Qt.Key_G),
    str(Qt.Key_B),
    str(Qt.Key_H),
    str(Qt.Key_N),
    str(Qt.Key_J),
    str(Qt.Key_M),
    # 2nd octave
    str(Qt.Key_Q),
    str(Qt.Key_2),
    str(Qt.Key_W),
    str(Qt.Key_3),
    str(Qt.Key_E),
    str(Qt.Key_R),
    str(Qt.Key_5),
    str(Qt.Key_T),
    str(Qt.Key_6),
    str(Qt.Key_Z),
    str(Qt.Key_7),
    str(Qt.Key_U),
    # 3rd octave
    str(Qt.Key_I),
    str(Qt.Key_9),
    str(Qt.Key_O),
    str(Qt.Key_0),
    str(Qt.Key_P),
]
kPcKeys_azerty = [
    # 1st octave
    str(Qt.Key_W),
    str(Qt.Key_S),
    str(Qt.Key_X),
    str(Qt.Key_D),
    str(Qt.Key_C),
    str(Qt.Key_V),
    str(Qt.Key_G),
    str(Qt.Key_B),
    str(Qt.Key_H),
    str(Qt.Key_N),
    str(Qt.Key_J),
    str(Qt.Key_Comma),
    # 2nd octave
    str(Qt.Key_A),
    str(Qt.Key_Eacute),
    str(Qt.Key_Z),
    str(Qt.Key_QuoteDbl),
    str(Qt.Key_E),
    str(Qt.Key_R),
    str(Qt.Key_ParenLeft),
    str(Qt.Key_T),
    str(Qt.Key_Minus),
    str(Qt.Key_Y),
    str(Qt.Key_Egrave),
    str(Qt.Key_U),
    # 3rd octave
    str(Qt.Key_I),
    str(Qt.Key_Ccedilla),
    str(Qt.Key_O),
    str(Qt.Key_Agrave),
    str(Qt.Key_P),
]
kPcKeysLayouts = {
    'qwerty': kPcKeys_qwerty,
    'qwertz': kPcKeys_qwertz,
    'azerty': kPcKeys_azerty,
}
kValidColors = ("Blue", "Green", "Orange", "Red")
kBlackNotes = (1, 3, 6, 8, 10)
# ------------------------------------------------------------------------------------------------------------
def _isNoteBlack(note):
    baseNote = note % 12
    return bool(baseNote in kBlackNotes)
# ------------------------------------------------------------------------------------------------------------
# MIDI Keyboard, using a pixmap for painting
class PixmapKeyboard(QWidget):
    # signals
    noteOn   = pyqtSignal(int)
    noteOff  = pyqtSignal(int)
    notesOn  = pyqtSignal()
    notesOff = pyqtSignal()
    def __init__(self, parent):
        QWidget.__init__(self, parent)
        self.fEnabledKeys   = []
        self.fLastMouseNote = -1
        self.fStartOctave   = 0
        self.fPcKeybOffset  = 2
        self.fInitalizing   = True
        self.fFont = self.font()
        self.fFont.setFamily("Monospace")
        self.fFont.setPixelSize(12)
        self.fFont.setBold(True)
        self.fPixmapNormal   = QPixmap(":/bitmaps/kbd_normal.png")
        self.fPixmapDown     = QPixmap(":/bitmaps/kbd_down-blue.png")
        self.fHighlightColor = kValidColors[0]
        self.fkPcKeyLayout = "qwerty"
        self.fkPcKeys      = kPcKeysLayouts["qwerty"]
        self.fKey2RectMap  = kMidiKey2RectMapHorizontal
        self.fWidth  = self.fPixmapNormal.width()
        self.fHeight = self.fPixmapNormal.height()
        self.setCursor(Qt.PointingHandCursor)
        self.setStartOctave(0)
        self.setOctaves(6)
        self.loadSettings()
        self.fInitalizing = False
    def saveSettings(self):
        if self.fInitalizing:
            return
        settings = QSafeSettings("falkTX", "CarlaKeyboard")
        settings.setValue("PcKeyboardLayout", self.fkPcKeyLayout)
        settings.setValue("PcKeyboardOffset", self.fPcKeybOffset)
        settings.setValue("HighlightColor", self.fHighlightColor)
        del settings
    def loadSettings(self):
        settings = QSafeSettings("falkTX", "CarlaKeyboard")
        self.setPcKeyboardLayout(settings.value("PcKeyboardLayout", self.fkPcKeyLayout, str))
        self.setPcKeyboardOffset(settings.value("PcKeyboardOffset", self.fPcKeybOffset, int))
        self.setColor(settings.value("HighlightColor", self.fHighlightColor, str))
        del settings
    def allNotesOff(self, sendSignal=True):
        self.fEnabledKeys = []
        if sendSignal:
            self.notesOff.emit()
        self.update()
    def sendNoteOn(self, note, sendSignal=True):
        if 0 <= note <= 127 and note not in self.fEnabledKeys:
            self.fEnabledKeys.append(note)
            if sendSignal:
                self.noteOn.emit(note)
            self.update()
        if len(self.fEnabledKeys) == 1:
            self.notesOn.emit()
    def sendNoteOff(self, note, sendSignal=True):
        if 0 <= note <= 127 and note in self.fEnabledKeys:
            self.fEnabledKeys.remove(note)
            if sendSignal:
                self.noteOff.emit(note)
            self.update()
        if len(self.fEnabledKeys) == 0:
            self.notesOff.emit()
    def setColor(self, color):
        if color not in kValidColors:
            return
        if self.fHighlightColor == color:
            return
        self.fHighlightColor = color
        self.fPixmapDown.load(":/bitmaps/kbd_down-{}.png".format(color.lower()))
        self.saveSettings()
    def setPcKeyboardLayout(self, layout):
        if layout not in kPcKeysLayouts.keys():
            return
        if self.fkPcKeyLayout == layout:
            return
        self.fkPcKeyLayout = layout
        self.fkPcKeys = kPcKeysLayouts[layout]
        self.saveSettings()
    def setPcKeyboardOffset(self, offset):
        if offset < 0:
            offset = 0
        elif offset > 9:
            offset = 9
        if self.fPcKeybOffset == offset:
            return
        self.fPcKeybOffset = offset
        self.saveSettings()
    def setOctaves(self, octaves):
        if octaves < 1:
            octaves = 1
        elif octaves > 10:
            octaves = 10
        self.fOctaves = octaves
        self.setMinimumSize(self.fWidth * self.fOctaves, self.fHeight)
        self.setMaximumSize(self.fWidth * self.fOctaves, self.fHeight)
    def setStartOctave(self, octave):
        if octave < 0:
            octave = 0
        elif octave > 9:
            octave = 9
        if self.fStartOctave == octave:
            return
        self.fStartOctave = octave
        self.update()
    def handleMousePos(self, pos):
        if pos.x() < 0 or pos.x() > self.fOctaves * self.fWidth:
            return
        octave = int(pos.x() / self.fWidth)
        keyPos = QPointF(pos.x() % self.fWidth, pos.y())
        if self.fKey2RectMap[1].contains(keyPos):    # C#
            note = 1
        elif self.fKey2RectMap[ 3].contains(keyPos): # D#
            note = 3
        elif self.fKey2RectMap[ 6].contains(keyPos): # F#
            note = 6
        elif self.fKey2RectMap[ 8].contains(keyPos): # G#
            note = 8
        elif self.fKey2RectMap[10].contains(keyPos): # A#
            note = 10
        elif self.fKey2RectMap[ 0].contains(keyPos): # C
            note = 0
        elif self.fKey2RectMap[ 2].contains(keyPos): # D
            note = 2
        elif self.fKey2RectMap[ 4].contains(keyPos): # E
            note = 4
        elif self.fKey2RectMap[ 5].contains(keyPos): # F
            note = 5
        elif self.fKey2RectMap[ 7].contains(keyPos): # G
            note = 7
        elif self.fKey2RectMap[ 9].contains(keyPos): # A
            note = 9
        elif self.fKey2RectMap[11].contains(keyPos): # B
            note = 11
        else:
            note = -1
        if note != -1:
            note += (self.fStartOctave + octave) * 12
            if self.fLastMouseNote != note:
                self.sendNoteOff(self.fLastMouseNote)
                self.sendNoteOn(note)
        elif self.fLastMouseNote != -1:
            self.sendNoteOff(self.fLastMouseNote)
        self.fLastMouseNote = note
    def showOptions(self, event):
        event.accept()
        menu = QMenu()
        menu.addAction(self.tr("Note: restart carla to apply globally")).setEnabled(False)
        menu.addAction(self.tr("Color")).setSeparator(True)
        groupColor  = QActionGroup(menu)
        groupLayout = QActionGroup(menu)
        actColors   = []
        actLayouts  = []
        menu.addAction(self.tr("Highlight color")).setSeparator(True)
        for color in kValidColors:
            act = menu.addAction(color)
            act.setActionGroup(groupColor)
            act.setCheckable(True)
            if self.fHighlightColor == color:
                act.setChecked(True)
            actColors.append(act)
        menu.addAction(self.tr("PC Keyboard layout")).setSeparator(True)
        for pcKeyLayout in kPcKeysLayouts.keys():
            act = menu.addAction(pcKeyLayout)
            act.setActionGroup(groupLayout)
            act.setCheckable(True)
            if self.fkPcKeyLayout == pcKeyLayout:
                act.setChecked(True)
            actLayouts.append(act)
        menu.addAction(self.tr("PC Keyboard base octave (%i)" % self.fPcKeybOffset)).setSeparator(True)
        actOctaveUp   = menu.addAction(self.tr("Octave up"))
        actOctaveDown = menu.addAction(self.tr("Octave down"))
        if self.fPcKeybOffset == 0:
            actOctaveDown.setEnabled(False)
        actSelected = menu.exec_(event.screenPos().toPoint())
        if not actSelected:
            return
        if actSelected in actColors:
            return self.setColor(actSelected.text())
        if actSelected in actLayouts:
            return self.setPcKeyboardLayout(actSelected.text())
        if actSelected == actOctaveUp:
            return self.setPcKeyboardOffset(self.fPcKeybOffset + 1)
        if actSelected == actOctaveDown:
            return self.setPcKeyboardOffset(self.fPcKeybOffset - 1)
    def minimumSizeHint(self):
        return QSize(self.fWidth, self.fHeight)
    def sizeHint(self):
        return QSize(self.fWidth * self.fOctaves, self.fHeight)
    def keyPressEvent(self, event):
        if not event.isAutoRepeat():
            try:
                qKey  = str(event.key())
                index = self.fkPcKeys.index(qKey)
            except:
                pass
            else:
                self.sendNoteOn(index+(self.fPcKeybOffset*12))
        QWidget.keyPressEvent(self, event)
    def keyReleaseEvent(self, event):
        if not event.isAutoRepeat():
            try:
                qKey  = str(event.key())
                index = self.fkPcKeys.index(qKey)
            except:
                pass
            else:
                self.sendNoteOff(index+(self.fPcKeybOffset*12))
        QWidget.keyReleaseEvent(self, event)
    def mousePressEvent(self, event):
        if event.button() == Qt.RightButton:
            self.showOptions(event)
        else:
            self.fLastMouseNote = -1
            self.handleMousePos(event.pos())
            self.setFocus()
        QWidget.mousePressEvent(self, event)
    def mouseMoveEvent(self, event):
        if event.button() != Qt.RightButton:
            self.handleMousePos(event.pos())
        QWidget.mouseMoveEvent(self, event)
    def mouseReleaseEvent(self, event):
        if self.fLastMouseNote != -1:
            self.sendNoteOff(self.fLastMouseNote)
            self.fLastMouseNote = -1
        QWidget.mouseReleaseEvent(self, event)
    def paintEvent(self, event):
        painter = QPainter(self)
        event.accept()
        # -------------------------------------------------------------
        # Paint clean keys (as background)
        for octave in range(self.fOctaves):
            target = QRectF(self.fWidth * octave, 0, self.fWidth, self.fHeight)
            source = QRectF(0, 0, self.fWidth, self.fHeight)
            painter.drawPixmap(target, self.fPixmapNormal, source)
        if not self.isEnabled():
            painter.setBrush(QColor(0, 0, 0, 150))
            painter.setPen(QColor(0, 0, 0, 150))
            painter.drawRect(0, 0, self.width(), self.height())
            return
        # -------------------------------------------------------------
        # Paint (white) pressed keys
        paintedWhite = False
        for note in self.fEnabledKeys:
            pos = self._getRectFromMidiNote(note)
            if _isNoteBlack(note):
                continue
            if note < 12:
                octave = 0
            elif note < 24:
                octave = 1
            elif note < 36:
                octave = 2
            elif note < 48:
                octave = 3
            elif note < 60:
                octave = 4
            elif note < 72:
                octave = 5
            elif note < 84:
                octave = 6
            elif note < 96:
                octave = 7
            elif note < 108:
                octave = 8
            elif note < 120:
                octave = 9
            elif note < 132:
                octave = 10
            else:
                # cannot paint this note
                continue
            octave -= self.fStartOctave
            target = QRectF(pos.x() + (self.fWidth * octave), 0, pos.width(), pos.height())
            source = QRectF(pos.x(), 0, pos.width(), pos.height())
            paintedWhite = True
            painter.drawPixmap(target, self.fPixmapDown, source)
        # -------------------------------------------------------------
        # Clear white keys border
        if paintedWhite:
            for octave in range(self.fOctaves):
                for note in kBlackNotes:
                    pos = self._getRectFromMidiNote(note)
                    target = QRectF(pos.x() + (self.fWidth * octave), 0, pos.width(), pos.height())
                    source = QRectF(pos.x(), 0, pos.width(), pos.height())
                    painter.drawPixmap(target, self.fPixmapNormal, source)
        # -------------------------------------------------------------
        # Paint (black) pressed keys
        for note in self.fEnabledKeys:
            pos = self._getRectFromMidiNote(note)
            if not _isNoteBlack(note):
                continue
            if note < 12:
                octave = 0
            elif note < 24:
                octave = 1
            elif note < 36:
                octave = 2
            elif note < 48:
                octave = 3
            elif note < 60:
                octave = 4
            elif note < 72:
                octave = 5
            elif note < 84:
                octave = 6
            elif note < 96:
                octave = 7
            elif note < 108:
                octave = 8
            elif note < 120:
                octave = 9
            elif note < 132:
                octave = 10
            else:
                # cannot paint this note
                continue
            octave -= self.fStartOctave
            target = QRectF(pos.x() + (self.fWidth * octave), 0, pos.width(), pos.height())
            source = QRectF(pos.x(), 0, pos.width(), pos.height())
            painter.drawPixmap(target, self.fPixmapDown, source)
        # Paint C-number note info
        painter.setFont(self.fFont)
        painter.setPen(Qt.black)
        for i in range(self.fOctaves):
            octave = self.fStartOctave + i - 1
            painter.drawText(i * 168 + (4 if octave == -1 else 3),
                             35, 20, 20,
                             Qt.AlignCenter,
                             "C{}".format(octave))
    def _getRectFromMidiNote(self, note):
        baseNote = note % 12
        return self.fKey2RectMap[baseNote]
# ---------------------------------------------------------------------------------------------------------------------
# Horizontal scroll area for keyboard
class PixmapKeyboardHArea(QScrollArea):
    def __init__(self, parent):
        QScrollArea.__init__(self, parent)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.keyboard = PixmapKeyboard(self)
        self.keyboard.setOctaves(10)
        self.setWidget(self.keyboard)
        self.setEnabled(False)
        self.setFixedHeight(int(self.keyboard.height() + self.horizontalScrollBar().height()/2 + 2))
        QTimer.singleShot(0, self.slot_initScrollbarValue)
    # FIXME use change event
    def setEnabled(self, yesNo):
        self.keyboard.setEnabled(yesNo)
        QScrollArea.setEnabled(self, yesNo)
    @pyqtSlot()
    def slot_initScrollbarValue(self):
        self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().maximum()/2))
# ---------------------------------------------------------------------------------------------------------------------
 |