2

I am attempting to create a GUI keyboard. I am using QPushButton to replicate the keys. I am not focusing on linking it to MIDI yet. However, I was wondering if it is possible to create a Class using properties of QPushButton. By that I mean functions such as QPushButton.move/resize/setStyleSheet.

As it currently stands, my code is very long (Shown below). This is due to constant repetition of code to make each button. However, I have noticed that properties such as size, stylesheet and location (only on the y-axis) are the same, with the main difference being the x-axis and the y-axis between the black and white keys. If I were to make a class, how would I able to create objects with various properties?

Once again, if my logic is flawed or I have made any errors, please feel free to let me know.

import sys

from PyQt5.QtWidgets import QMainWindow, QApplication, QWidget, QPushButton, QAction, QLineEdit, QMessageBox, QLabel \
    ,QDialog
from PyQt5.QtGui import QIcon, QHideEvent, QFont, QPixmap
from PyQt5.QtCore import pyqtSlot, QObject, QSize


class App(QMainWindow):

    def __init__(self):
        super().__init__()
        self.title = 'Piano '
        self.left = 620
        self.top =250
        self.width = 1920
        self.height = 1080
        self.initUI()

    def initUI(self):
        self.setWindowTitle(self.title)
        self.setGeometry(self.left, self.top, self.width, self.height)
        self.setFixedWidth(self.width)
        self.setFixedHeight(self.height)
        self.setStyleSheet("background-color:lightgrey")
        self.setWindowIcon(QIcon('img.png'))

        # Create a button in the window
        self.button1 = QPushButton('C', self)
        self.button1.move(10, 650)
        self.button1.setFont(QFont('Arial', 14))
        self.button1.resize(100,400)
        self.button1.setStyleSheet("background-color:white; border :1px solid ;") #Black Not Working

        self.button2 = QPushButton('D', self)
        self.button2.move(110,650)
        self.button2.setFont(QFont('Arial', 14))
        self.button2.resize(100, 400)
        self.button2.setStyleSheet("background-color:white; border :1px solid ;")

        self.button3 = QPushButton('E', self)
        self.button3.move(210, 650)
        self.button3.setFont(QFont('Arial', 14))
        self.button3.resize(100, 400)
        self.button3.setStyleSheet("background-color:white; border :1px solid ;")

        self.button4 = QPushButton('F', self)
        self.button4.move(310, 650)
        self.button4.setFont(QFont('Arial', 14))
        self.button4.resize(100, 400)
        self.button4.setStyleSheet("background-color:white; border :1px solid ;")

        self.button5 = QPushButton('G', self)
        self.button5.move(410, 650)
        self.button5.setFont(QFont('Arial', 14))
        self.button5.resize(100, 400)
        self.button5.setStyleSheet("background-color:white; border :1px solid ;")

        self.button6 = QPushButton('A', self)
        self.button6.move(510, 650)
        self.button6.setFont(QFont('Arial', 14))
        self.button6.resize(100, 400)
        self.button6.setStyleSheet("background-color:white; border :1px solid ;")

        self.button7 = QPushButton('B', self)
        self.button7.move(610, 650)
        self.button7.setFont(QFont('Arial', 14))
        self.button7.resize(100, 400)
        self.button7.setStyleSheet("background-color:white; border :1px solid ;")

        self.button8 = QPushButton('C2', self)
        self.button8.move(710, 650)
        self.button8.setFont(QFont('Arial', 14))
        self.button8.resize(100, 400)
        self.button8.setStyleSheet("background-color:white; border :1px solid ;")

        self.button2 = QPushButton('D2', self)
        self.button2.move(810, 650)
        self.button2.setFont(QFont('Arial', 14))
        self.button2.resize(100, 400)
        self.button2.setStyleSheet("background-color:white; border :1px solid ;")

        self.button3 = QPushButton('E2', self)
        self.button3.move(910, 650)
        self.button3.setFont(QFont('Arial', 14))
        self.button3.resize(100, 400)
        self.button3.setStyleSheet("background-color:white; border :1px solid ;")

        self.button4 = QPushButton('F2', self)
        self.button4.move(1010, 650)
        self.button4.setFont(QFont('Arial', 14))
        self.button4.resize(100, 400)
        self.button4.setStyleSheet("background-color:white; border :1px solid ;")

        self.button5 = QPushButton('G2', self)
        self.button5.move(1110, 650)
        self.button5.setFont(QFont('Arial', 14))
        self.button5.resize(100, 400)
        self.button5.setStyleSheet("background-color:white; border :1px solid ;")

        self.button6 = QPushButton('A2', self)
        self.button6.move(1210, 650)
        self.button6.setFont(QFont('Arial', 14))
        self.button6.resize(100, 400)
        self.button6.setStyleSheet("background-color:white; border :1px solid ;")

        self.button7 = QPushButton('B2', self)
        self.button7.move(1310, 650)
        self.button7.setFont(QFont('Arial', 14))
        self.button7.resize(100, 400)
        self.button7.setStyleSheet("background-color:white; border :1px solid ;")

        self.button8 = QPushButton('C3', self)
        self.button8.move(1410, 650)
        self.button8.setFont(QFont('Arial', 14))
        self.button8.resize(100, 400)
        self.button8.setStyleSheet("background-color:white; border :1px solid ;")

        self.button1up = QPushButton('C#', self)
        self.button1up.move(85, 650)
        self.button1up.setFont(QFont('Arial', 14))
        self.button1up.resize(50, 300)
        self.button1up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button2up = QPushButton('D#', self)
        self.button2up.move(185, 650)
        self.button2up.setFont(QFont('Arial', 14))
        self.button2up.resize(50, 300)
        self.button2up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button3up = QPushButton('F#', self)
        self.button3up.move(385, 650)
        self.button3up.setFont(QFont('Arial', 14))
        self.button3up.resize(50, 300)
        self.button3up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button4up = QPushButton('G#', self)
        self.button4up.move(485, 650)
        self.button4up.setFont(QFont('Arial', 14))
        self.button4up.resize(50, 300)
        self.button4up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button5up = QPushButton('A#', self)
        self.button5up.move(585, 650)
        self.button5up.setFont(QFont('Arial', 14))
        self.button5up.resize(50, 300)
        self.button5up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button6up = QPushButton(self)
        self.button6up.move(785, 650)
        self.button6up.setFont(QFont('Arial', 14))
        self.button6up.resize(50, 300)
        self.button6up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button7up = QPushButton(self)
        self.button7up.move(885, 650)
        self.button7up.setFont(QFont('Arial', 14))
        self.button7up.resize(50, 300)
        self.button7up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button8up = QPushButton(self)
        self.button8up.move(1085, 650)
        self.button8up.setFont(QFont('Arial', 14))
        self.button8up.resize(50, 300)
        self.button8up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button9up = QPushButton(self)
        self.button9up.move(1185, 650)
        self.button9up.setFont(QFont('Arial', 14))
        self.button9up.resize(50, 300)
        self.button9up.setStyleSheet("background-color:black; border :1px solid ;")

        self.button10up = QPushButton(self)
        self.button10up.move(1285, 650)
        self.button10up.setFont(QFont('Arial', 14))
        self.button10up.resize(50, 300)
        self.button10up.setStyleSheet("background-color:black; border :1px solid ;")

        # connect button to function on_click
        self.button1.clicked.connect(self.b1)

        self.show()

    @pyqtSlot()
    def b1(self):
       pass


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = App()
    sys.exit(app.exec_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241

1 Answers1

4

The first logic solution would be to create a list of data for each key, something like this:

KeyMap = (
    # key name, x, y, width, height, color
    ('C', 10, 650, 100, 400, 'white'),
    # ...
    ('C#', 85, 650, 50, 300, 'black'),
)

Then cycle through the list:

    baseStyle = 'background-color: {}; border :1px solid;'
    for key, x, y, width, height, color in KeyMap:
        button = QPushButton('key', self)
        button.setGeometry(x, y, width, height)
        button.setFont(QFont('Arial', 14))
        button.setStyleSheet(baseStyle.format(color)

Unfortunately, while the above might work fine for simple examples, I can tell you from experience that it won't work very well for this scenario (a piano-like keyboard), and mostly for these two reasons:

  • fixed geometries are rarely a good idea, if you need to resize the keyboard for any reason (including make it fit the screen, especially if its small) it will make everything much harder to code by hand;
  • you might need to extend the keyboard to more octaves or want to change the ratio between the widths and heights of keys;

I've had my share of experience on trying to create UI keyboards, and I ended up with a completely custom widget (based on a QGraphicsView), but for simple cases the following might suit your needs.

The trick is to use a Qt grid layout, which has the interesting "feature" of allowing items being overlapped if placed between its "cells".

The following code uses 2 rows (one for both white and black keys on top, one at the bottom for white keys) and 3 columns for each white key: while white keys use all three columns, black ones use the third column of the previous white key, and the first of the next; imagine a table like this:

+---+---+---+---+---+---+---+---+---+
|   ·   |   ·   |   |   ·   |   ·   |
|   ·   |   C   |   |   D   |   ·   |
|   ·   | sharp |   | sharp |   ·   |
|   ·   |   ·   |   |   ·   |   ·   |
+ · · · +---+---+ · +---+---+ · · · +
|   ·   ·   |   ·   ·   |   ·   ·   |
|   · C ·   |   · D ·   |   · E ·   |
|   ·   ·   |   ·   ·   |   ·   ·   |
+---+---+---+---+---+---+---+---+---+

Then you can create a basic class for the keys (including a signal for key triggering on press and release), and use simple lists to easily get if a key is white or black and place/style them accordingly.

The optional arguments are obviously for the octave range, and the octave start (since you're probably going to use MIDI, that's important, as you'll automatically get the key value).

nice keyboard

from PyQt5 import QtCore, QtWidgets

BlackIdx = 1, 3, -1, 6, 8, 10
WhiteIdx = 0, 2, 4, 5, 7, 9, 11
KeyNames = 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'


class KeyButton(QtWidgets.QPushButton):
    triggered = QtCore.pyqtSignal(int, bool)
    def __init__(self, key, isBlack=False):
        super().__init__()
        self.key = key
        # this will be used by the stylesheet
        self.setProperty('isBlack', isBlack)

        octave, keyIdx = divmod(key, 12)
        self.setText('{}{}'.format(KeyNames[keyIdx], octave))

        self.setMinimumWidth(25)
        self.setMinimumSize(25, 80 if isBlack else 120)
        
        # ensure that the key expands vertically
        self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, 
            QtWidgets.QSizePolicy.Expanding)

        # connect the pressed and released (not clicked!) signals to our custom one
        self.pressed.connect(lambda: self.triggered.emit(key, True))
        self.released.connect(lambda: self.triggered.emit(key, False))


class Keyboard(QtWidgets.QWidget):
    def __init__(self, octaves=2, octaveStart=3):
        super().__init__()
        layout = QtWidgets.QGridLayout(self)

        # the ratio between key heights: white keys are 1/3 longer than black ones
        layout.setRowStretch(0, 2)
        layout.setRowStretch(1, 1)
        layout.setSpacing(0)

        blackKeys = []
        for octave in range(octaves):
            for k in range(12):
                isBlack = k in BlackIdx
                keyButton = KeyButton(k + (octaveStart + octave) * 12, isBlack)
                keyButton.triggered.connect(self.keyTriggered)
                if isBlack:
                    keyPos = BlackIdx.index(k)
                    # column based on the index of the key list, plus 2 "cells"
                    col = keyPos * 3 + 2
                    # only one row in the layout
                    vSpan = 1
                    # only two columns
                    hSpan = 2
                    blackKeys.append(keyButton)
                else:
                    keyPos = WhiteIdx.index(k)
                    col = keyPos * 3
                    # two rows
                    vSpan = 2
                    # three columns
                    hSpan = 3
                col += octave * 21
                layout.addWidget(keyButton, 0, col, vSpan, hSpan)

            # "blank" spacers between E-F and B-C, to keep the spacings homogeneous
            efSpacer = QtWidgets.QWidget()
            efSpacer.setMinimumWidth(25)
            layout.addWidget(efSpacer, 0, octave * 21 + 8, 1, 2)
            efSpacer.lower()
            baSpacer = QtWidgets.QWidget()
            baSpacer.setMinimumWidth(25)
            layout.addWidget(baSpacer, 0, octave * 21 + 20, 1, 2)
            baSpacer.lower()

        # the last C note, with a minimum width a bit bigger
        octave += 1
        lastButton = KeyButton((octaveStart + octave) * 12)
        lastButton.setMinimumWidth(32)
        lastButton.triggered.connect(self.keyTriggered)
        layout.addWidget(lastButton, 0, octave * 21, 2, 3)

        # raise all black keys on top of everything else
        for keyButton in blackKeys:
            keyButton.raise_()

        # set the stretch of layout cells, if it's in the middle, it's bigger
        for col in range(layout.columnCount()):
            if col % 3 == 1:
                layout.setColumnStretch(col, 4)
            else:
                layout.setColumnStretch(col, 3)

        self.setStyleSheet('''
            KeyButton {
                color: rgb(50, 50, 50);
                border: 1px outset rgb(128, 128, 128);
                border-radius: 2px;
                background: white;
            }
            KeyButton:pressed {
                border-style: inset;
            }
            KeyButton[isBlack=true] {
                color: rgb(250, 250, 250);
                background: black;
            }
            KeyButton[isBlack=true]:pressed {
                background: rgb(50, 50, 50);
            }
        ''')

    def keyTriggered(self, key, pressed):
        octave, keyIdx = divmod(key, 12)
        keyName = '{}{}'.format(KeyNames[keyIdx], octave)
        state = 'pressed' if pressed else 'released'
        print('Key {} ({}) {}'.format(key, keyName, state))


if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    keyboard = Keyboard()
    keyboard.show()
    sys.exit(app.exec_())

PS: I've created a gist with a keyboard I made some years ago. Do note that this code is quite old and I was still a bit unexperienced at the time (you might need to change the import statement, as it was aimed for the Qt.py module which allows transparent integration of PyQt4/5 and PySide):
https://gist.github.com/MaurizioB/43a053575f17eae371a9d7394e66a46e

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thank you for your answer. However the issue is that I would like the piano to be located in a particular area of my GUI. I'm making a replica MIDI controller (This was only to show code for the keyboard and I'd like to located the keyboard in a specific area if that makes sense. – GetMoney_G5 Feb 12 '21 at 10:33
  • 1
    @GetMoney_G5 the suggestion about avoiding fixed geometries is still valid: consider that what you see on your screen is almost **never** what others will, and that's because of various aspects. For instance, if a user has a default font which is bigger than yours, many widgets will be "clipped" or overlapped with others; another aspect is the screen resolution: for example, with your code, I am not able to see the whole interface, and the result is that most of your keyboard is not visible; then you have pixel density: high DPI screens (4k, or even 8k) will make your program *too* small. – musicamante Feb 12 '21 at 10:43
  • 1
    You want to imitate a physical object, and I get it: I created some synth/controller editors myself. But those objects have a fixed physical size based on "human proportions", while "computer screens" have almost infinite combinations of resolution, pixel density, system settings, etc. There's a reason for which any modern and decent website uses a "responsive" design: it has to adapt to the screen and device it's being shown on, and you cannot ignore that aspect; you need to find the right balance, which means using layouts. Any other solution will certainly make your program almost unusable. – musicamante Feb 12 '21 at 10:50
  • I'm starting to understand that. However, my main issue is understanding when I implement it into my own GUI. For context, the GUI is styled like a MIDI controller, like the AKAI mini, with the piano in the bottom right. So with that, how would the resizing work. And how would I implement it into my main GUI? – GetMoney_G5 Feb 12 '21 at 13:27
  • That's up to you, you'll probably need to use various levels of nested layouts. Consider [this](http://bigglesworth.it/images/screenshots/edit.jpg): it has more than 60 layouts, on about 8 levels of widgets with their own (sometimes nested) layouts. Doing something like that using fixed geometries only would be a nightmare. In the example of the AKAI mini, you could use a main VBoxLayout, with the keyboard on bottom, while on top a HBoxLayout with: a grid for the left buttons, another grid for the 8 pads, and another vbox on the right, which in turn has an hbox on top and a grid for the knobs. – musicamante Feb 12 '21 at 13:59
  • I suggest you to do some testing using Designer, so that you can better understand its structure or try possible alternatives, then you can still recreate it by code if you prefer to do so. – musicamante Feb 12 '21 at 14:00