This is a simple app that takes a bunch of pngs, each being a single line of sheet music, and displays some of them. It responds to OSC messages to advance one line, or one screen, and to toggle fullscreen. And there are keyboard shortcuts. It illustrates the use of `QPixmap` and `QPainter`s, along with simple OSC control. ```py #!/usr/bin/env python3 help = """ Sheet music viewer. Have individual lines in files. Either number these 1.png 2.png... and run in that directory, or specify the line files, in order, on the command line. If the first argument is numeric, it is taken as the initial number of lines per screen. Keys: f -- fullscreen space -- advance one line pgdn -- advance one screen pgup -- go back one screen -/= -- dec/inc number of lines per screen OSC (port 4011): /f -- fullscreen /next -- next /pgdn -- advance one screen /pgup -- go back one screen /numlines x -- if x is an int, set that numer of lines, if "+", inc, if "-" dec /goto n -- goto line n /forward n -- go forward n /back n -- go back n """ from PySide6.QtCore import * from PySide6.QtGui import * from PySide6.QtWidgets import * from PySide6.QtNetwork import * from glob import glob from pythonosc.osc_message import OscMessage import os import sys app = QApplication([]) class Sheet(QWidget): def __init__(self,*xs,pixmaps=None,num_lines=4,**kw): super().__init__(*xs,**kw) self.idx = 0 self.pixmaps = pixmaps if pixmaps is None: raise ValueError("No pixmaps") self.select(self.idx) self.num_lines = num_lines self.full_screen = False def select(self,idx): self.idx = idx % len(self.pixmaps) self.update() def next(self): self.select(self.idx+1) def nextPage(self): self.select(self.idx+self.num_lines) def prevPage(self): self.select(self.idx-self.num_lines) def paintEvent(self,e): with QPainter(self) as qp: rect = self.rect() width = rect.width() height = rect.height() line_height = int(height/self.num_lines) font = QFont("Arial",24) metrics = QFontMetrics(font) qp.setFont(font) qp.fillRect(rect,QBrush(Qt.white)) qp.setBrush(Qt.black) qp.setPen(QPen(Qt.black)) for i in range(self.num_lines): y = i*line_height pidx = (self.idx + i) % len(self.pixmaps) pixmap = self.pixmaps[pidx].scaledToHeight(line_height,Qt.SmoothTransformation) pwidth = pixmap.width() dw = width - pwidth if dw < 80: pixmap = pixmap.scaledToWidth(width-80) pwidth = pixmap.width() dw = width - pwidth dw2 = dw//2 qp.drawPixmap(dw2,y,pixmap) line_num = str(((self.idx+i)%len(self.pixmaps))+1) brect = metrics.boundingRect(line_num) bwidth = brect.width() bheight = brect.height() tx = dw2-5-bwidth ty = y + (line_height - bheight)//2 + metrics.ascent() qp.drawText(QPoint(tx,ty),line_num) def changeNumLines(self,n): if isinstance(n,str): if n.isnumeric(): n = int(n) elif n == "+": n = self.num_lines+1 elif n == "-": n = self.num_lines-1 elif isinstance(n,float): n = int(n) n = min(6,max(2,n)) self.num_lines = n self.update() def goto(self,n): try: n = int(n) except ValueError: print(f"Ignoring goto {n}") return self.select(n-1) def forward(self,n): try: n = self.idx + int(n) except ValueError: print(f"Ignoring goto {n}") return self.select(n) def back(self,n): try: n = self.idx - int(n) except ValueError: print(f"Ignoring goto {n}") return self.select(n) def keyPressEvent(self,e): print(e) k = e.key() if k == Qt.Key_F: self.toggleFullScreen() elif k == Qt.Key_Q: app.quit() elif k == Qt.Key_Space: self.select(self.idx+1) elif k == Qt.Key_PageUp: self.select(self.idx-self.num_lines) elif k == Qt.Key_PageDown: self.select(self.idx+self.num_lines) elif k == Qt.Key_Equal: self.num_lines = min(6,self.num_lines+1) self.update() elif k == Qt.Key_Minus: self.num_lines = max(2,self.num_lines-1) self.update() return super().keyPressEvent(e) def toggleFullScreen(self): self.full_screen = not self.full_screen if self.full_screen: self.setWindowState(self.windowState() | Qt.WindowFullScreen) else: self.setWindowState(self.windowState() & ~Qt.WindowFullScreen) class Receiver(QObject): def __init__(self,target,port=4011): self.target = target self.udpSocket = QUdpSocket() self.udpSocket.readyRead.connect(self.handleUdp) self.udpSocket.bind(QHostAddress.Any,port) def handleUdp(self): while self.udpSocket.hasPendingDatagrams(): datagram = self.udpSocket.receiveDatagram(4096) data = datagram.data().data() print(f"Recv {data=} {len(data)} bytes") try: message = OscMessage(data) except Exception as e: print(f"Failed to parse {e} : {data=}") return self.processMessage(message) def processMessage(self,message): addr = message.address params = message.params if addr.lower() == "next": self.target.next() elif addr.lower() in ["f","fs","fullscreen"]: self.target.toggleFullScreen() elif addr.lower() in ["pgup","pageup"]: self.target.prevPage() elif addr.lower() in ["pgdn","pagedown"]: self.target.nextPage() elif addr.lower() == "goto": if len(params) == 1: self.target.goto(params[0]) else: print(f"Wrong params to numlines") elif addr.lower() == "forward": if len(params) == 1: self.target.forward(params[0]) else: print(f"Wrong params to numlines") elif addr.lower() == "back": if len(params) == 1: self.target.back(params[0]) else: print(f"Wrong params to numlines") elif addr.lower() == "numlines": if len(params) == 1: self.target.changeNumLines(params[0]) else: print(f"Wrong params to numlines") elif addr.lower() in ["pgdn","pagedown"]: self.target.nextPage() else: print(f"Unrecognised {addr=}") args = sys.argv[1:] num_lines = 4 if len(args) > 0: arg = args[0] if arg.isnumeric(): num_lines = int(arg) num_lines = max(min(6,num_lines),2) args = args[1:] if len(args) > 0: fns = args else: i = 1 fns = [] while os.path.exists(fn:=f"{i}.png"): fns.append(fn) i += 1 pixmaps = [ QPixmap(fn) for fn in fns ] sheet = Sheet(pixmaps=pixmaps,num_lines=num_lines) sheet.resize(900,800) sheet.show() receiver = Receiver(sheet,4011) exit(app.exec()) ```