Dup Ver Goto 📝

Simple Flashcard App

PT2/lang/python/qt/pyside6 does not exist
To
489 lines, 1398 words, 14040 chars Page 'FlashcardApp_01' does not exist.

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)