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 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.
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.
PORT = 4004
# UDP messages
# When and if this gets rewritten, use OSC instead.
control_messages = """
fontsize <n>
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)