title: Simple Flashcard App # Simple Flashcard App Something I sometimes put on when working out on the Wattbike go keep my brain occupied, is to print verses (or fragments of verses) of e.g. the Bible or the Dhammapada to repeat to myself, also to aid in learning and memorising. All this does is to display one fragment, advancing every 30s (or some other period), with a border that runs around the screen like a clock. # Programming Philosophy Nothing fancy, but suits my purposes. As you might imagine, I like to 'dogfood' things, in the sense that I have written something that suits my purposes, I 'eat my own dogfood', and share the results. I'm not, as you might guess, trying to write something for a general audience: there are no studies of customer requirements, or planning meetings. My planning meetings are a quick scribble on notepad or kate or vim as to what I want, and if I can think how to easily make it, I do. That's my philosophy with a lot of what you find here: scratch an itch with the minimal viable solution. If things get unwieldy, then we have experience from which to decide what features and behaviour a rewrite should have. For example this wiki came after a few iterations like that: first a Wabisabi clone called The Wiki Man, then a rewrite that was originally intended to be version 2 of a [how-to-write-a-wiki youtube tutorial](https://www.youtube.com/watch?v=7eMsQUQKSvw) that I never go around to. So I started using that (still flat rather than hierarchical, using a single instance of the wiki for each subdirectory), and then eventually redesigning and rewriting the entire thing into the wiki you're currently reading. # The Code ## Driver This is the simple driver program, `sub3.py`. The main work is done in the next module. ```py import sys import os import json import socket from setproctitle import setproctitle; setproctitle("sub3") from icecream import ic; ic.configureOutput(includeContext=True) # This allows other things on the network to do something when # the flashcard changes. It uses OSC, which uses UDP, so silently # does nothing if nothing is listening on the appropriate UDP port. notify_hosts=["random","turnip"] def send_message(host,port,message): with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as s: s.connect((host,port)) s.send(message) def upd_callback(text): for host in notify_hosts: send_message(host,4008,text.encode()) # load config with open("config.json") as f: config = json.load(f) # simple argument parser # I should rewrite this using argparse at some point args = sys.argv[1:] if len(args) > 0: config["filename"] = args[0] if len(args) > 1: config["fontsize"] = int(args[1]) fontsize = min(160,max(40,config["fontsize"])) ifn = config["filename"] if not os.path.isfile(ifn): print(f"{ifn} does not exist, or is not a file") exit(2) with open(ifn) as f: src = f.read().rstrip().splitlines() if "subtime" in config: subtime = config["subtime"] else: subtime = 30000 # this allows a default to also be supplied via an environment variable # currently config.json overrides this, which is the wrong way around. subtime = int(os.getenv("SUBTIME",subtime)) # now the Qt stuff from PySide6.QtWidgets import QApplication from subt import Subtitles as st app = QApplication(sys.argv) thesubt = st(subtime,src,surround=True,fontsize=fontsize,bgcolor="black",upd_callback=upd_callback) thesubt.show() thesubt.start() try: exit(app.exec()) except KeyboardInterrupt: print("Ctrl-C 2") ``` ## The Main Engine This is as it is. It is a bit of a mess. But it works, so I haven't found the need to rewrite it. ```py PORT = 4004 # UDP messages # When and if this gets rewritten, use OSC instead. control_messages = """ fontsize sohwlast quit hide hudcolor hudcolorf subtime """ from icecream import ic; ic.configureOutput(includeContext = True) import sys import math import re import os from PySide6.QtCore import * from PySide6.QtGui import * from PySide6.QtWidgets import * from PySide6.QtNetwork import * from datetime import datetime def clamp(x,a,b): return max(min(x,b),a) class Subtitles(QWidget): def handleUdp(self): try: while self.udpSocket.hasPendingDatagrams(): datagram = self.udpSocket.receiveDatagram(self.port) data = datagram.data().data() self.process_data(data.decode()) except KeyboardInterrupt: print("Ctrl-C") QApplication.instance().quit() def process_data(self,data): data = data[:256].strip() cmd,*params = re.split(r"\s+",data,1) match cmd: case "quit": return QApplication.instance().quit() case "fontsize": try: new_font_size = int(params[0]) new_font_size = min(160,max(32,new_font_size)) self.fontsize = new_font_size self.font = QFont(self.fontname,self.fontsize) self.font.setBold(self.bold) self.font.setItalic(self.italic) self.update() return except Exception: ic("#fail",cmd,params) case "bold": if len(params) > 0: if params[0].lower() in ["true","yes","1"]: self.bold = True else: self.bold = False else: self.bold = not self.bold self.font.setBold(self.bold) self.update() return case "italic": if len(params) > 0: if params[0].lower() in ["true","yes","1"]: self.italic = True else: self.italic = False else: self.italic = not self.italic self.font.setItalic(self.italic) self.update() return case "fontname": try: self.fontname = params[0] self.font = QFont(self.fontname,self.fontsize) self.font.setBold(self.bold) self.font.setItalic(self.italic) self.update() return except Exception: ic("#fail",cmd,params) case "period": try: new_period = int(params[0]) new_period = min(300,max(5,new_period)) new_period *= 1000 self.subtime = new_period self.timer.setInterval(new_period) return except Exception: ic("#fail",cmd,params) case "tick": self.tick() self.timer.stop() self.timer.start() case "next": self.next() self.timer.stop() self.timer.start() case "prev": self.prev() self.timer.stop() self.timer.start() case "pause": self.stop() case "play": self.start() def keyPressEvent(self,e): k = e.key() m = e.modifiers() mv = m.value sh = Qt.ShiftModifier.value ct = Qt.ControlModifier.value al = Qt.AltModifier.value me = Qt.MetaModifier.value if mv&ct or mv&al or mv&me: return super().keyPressEvent(e) if k == Qt.Key_P: self.prev() self.timer.stop() self.timer.start() elif k == Qt.Key_N: self.next() self.timer.stop() self.timer.start() elif k == Qt.Key_Space: self.tick() self.timer.stop() self.timer.start() elif k == Qt.Key_Q: QApplication.instance().quit() def __init__(self,subtime,src,surround=True,fontsize=120,voffset=0,hoffset=50,port=PORT,group=1,bgcolor=None,upd_callback=None,start=0): super().__init__() self.app = QApplication.instance() self.port = port print(f"UDP port is {port}") self.running = False self.upd_callback = upd_callback self.bgcolor = bgcolor self.groupsize = group self.surround = surround self.subtime = subtime self.lines = src self.index = start % len(self.lines) self.voffset = voffset self.hoffset = hoffset self.udpSocket = QUdpSocket() self.udpSocket.readyRead.connect(self.handleUdp) self.udpSocket.bind(QHostAddress.Any,port) print(f"Listening on UDP port {port}") # size and position – possibly necessary to recompute, resize and move depending on hud contents # attributes for display self.content = "" try: self.fontsize = int(os.getenv("FONTSIZE",fontsize)) except Exception: self.fontsize = 120 try: self.fontname = os.getenv("FONTNAME","Optima") except Exception: self.fontname = "Optima" self.bold = False self.italic = False self.font = QFont(self.fontname,self.fontsize) self.colors = [ QColor(100,255,100), QColor(255,100,100), QColor(100,100,255), QColor(255,255,100), QColor(100,255,255), QColor(255,100,255) ] self.coloridx = 0 self.ocolor = Qt.black self.pen = QPen(self.ocolor,self.fontsize/10) self.timer = QTimer(self) self.subtime = subtime self.timer.setInterval(self.subtime) screen = QGuiApplication.primaryScreen() geometry = screen.geometry() self.height = geometry.height() self.width = geometry.width() self.move(0,0) self.resize(self.width,self.height) self.inset = 40 self.hh = self.height - 2*self.inset self.ww = self.width - 2*self.inset self.perimeter = (self.hh+self.ww)*2 # window attributes self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint ) self.setAttribute(Qt.WA_TranslucentBackground) self.timer.timeout.connect(self.tick) if surround: self.timer2 = QTimer(self) self.timer2.setInterval(1000/30) self.timer2.timeout.connect(self.tick2) if self.upd_callback is not None: self.upd_callback(self.lines[self.index]) def start(self): if self.running: return self.timer.start() if self.surround: self.timer2.start() self.tick2() self.running = True def stop(self): if not self.running: return self.timer.stop() if self.surround: self.timer2.stop() self.running = False def next(self): try: self.index = (self.index + 1) % len(self.lines) self.coloridx = (self.coloridx + 1) % len(self.colors) self.hide() self.show() if self.upd_callback is not None: self.upd_callback(self.lines[self.index]) self.update() except KeyboardInterrupt: print("Ctrl-C") QApplication.instance().quit() def prev(self): try: self.index = (self.index + len(self.lines) - 1) % len(self.lines) self.coloridx = (self.coloridx + 1) % len(self.colors) self.hide() self.show() if self.upd_callback is not None: self.upd_callback(self.lines[self.index]) self.update() except KeyboardInterrupt: print("Ctrl-C") QApplication.instance().quit() def tick(self): try: self.index = (self.index + 1) % len(self.lines) self.coloridx = (self.coloridx + 1) % len(self.colors) self.hide() self.show() if self.upd_callback is not None: self.upd_callback(self.lines[self.index]) self.update() except KeyboardInterrupt: print("Ctrl-C") QApplication.instance().quit() def tick2(self): try: dt = self.timer.interval() - self.timer.remainingTime() dx = dt / self.subtime dx = max(dx,0.0) self.dx = dx self.dl = dx * self.perimeter #ic(dt,dx) self.update() except KeyboardInterrupt: print("Ctrl-C") QApplication.instance().quit() def mousePressEvent(self,event): QApplication.instance().quit() def paintEvent(self,event): with QPainter(self) as p: rect = self.rect() if self.bgcolor is not None and False: p.fillRect(rect,QColor(self.bgcolor)) if self.surround: trect = rect.adjusted(self.inset,self.inset,-self.inset*2,-self.inset*2) else: trect = rect self.drawPerimeter(p) p.setFont(self.font) p.setPen(self.pen) text = " ".join((t.strip() for t in self.lines[self.index:self.index+self.groupsize])) metrics = QFontMetrics(self.font) brect = metrics.boundingRect(text) nlines = math.ceil((brect.width()*1.2)/self.width) words = re.split(r"\s+",text) h = metrics.height() i = 0 curw = 0 x = trect.left() + self.hoffset y = int(self.fontsize*1.5) + trect.top() + self.voffset gap = int(self.fontsize * 0.5) for word in words: #print(f"{word} ",end="") wrect = metrics.boundingRect(word) if curw == 0: self.drawText(p,x,y+i*h,word) curw = wrect.width() + gap #print() continue neww = curw + wrect.width() if neww >= trect.width() - 100: i += 1 curw = 0 self.drawText(p,x,y+i*h,word) curw = wrect.width() + gap #print() continue #ic(word,x,y,i,h,curw,word) self.drawText(p,x+curw,y+i*h,word) curw += wrect.width() + gap #print() def drawPerimeter(self,p): if not self.surround: return i = self.inset w = self.ww w2 = w/2 h = self.hh dl = self.dl dx = self.dx pe = self.perimeter color = QColor() color.setHslF(0.33*(1-dx),1.0,0.5) p.setPen(QPen(color,30)) p.setBrush(Qt.NoBrush) path = QPainterPath() path.moveTo(i+w2,i) l = min(dl,w2) path.lineTo(i+w2+l,i) dl -= l if dl > 0: l = min(dl,h) path.lineTo(i+w,i+l) dl -= l if dl > 0: l = min(dl,w) path.lineTo(i+w-l,i+h) dl -= l if dl > 0: l = min(dl,h) path.lineTo(i,i+h-l) dl -= l if dl > 0: l = min(dl,w2) path.lineTo(i+l,i) p.drawPath(path) def drawText(self,p,x,y,text): path = QPainterPath() path.addText(x,y,self.font,text) # draw twice so that the black outline is _behind_ the fill p.setPen(self.pen) p.drawPath(path) p.setBrush(self.colors[self.coloridx]) p.setPen(Qt.NoPen) p.drawPath(path) ```