Skip to content

Instantly share code, notes, and snippets.

@coleifer
Created June 21, 2026 15:02
Show Gist options
  • Select an option

  • Save coleifer/cf1215acc460382ae75ac9945c0309b2 to your computer and use it in GitHub Desktop.

Select an option

Save coleifer/cf1215acc460382ae75ac9945c0309b2 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
====================================================================================
THE FILTHY LIST -- a Spumco TODO
====================================================================================
UI design: 100% John Kricfalusi & Bob Camp. Every line, every pixel, every
bloodshot vein. (They are asleep. We rebuilt it from memory after the dog ate
the files.)
Mustard rotting wall. One hard light from the upper-left. Everything lumpy and
ink-edged. One hot specular dot per shiny thing. A big red button screaming
DON'T PRESS. Filthy. Beautiful. Spumco.
Single self-contained PySide6 (Qt6) module. NO external image/font/sound files:
every shape is painted at runtime with QPainter; everything quivers off ONE
50ms heartbeat. Persists to ~/.spumco_todo.json (atomic, debounced).
Run: python3 filthy_list.py
====================================================================================
"""
import json
import math
import os
import random
import shutil
import sys
import time
import uuid
from dataclasses import dataclass, field, asdict
from PySide6.QtCore import (Qt, QPointF, QRectF, QTimer, QObject, Signal,
QPropertyAnimation, QEasingCurve, Property, QSize)
from PySide6.QtGui import (QPainter, QPainterPath, QPen, QBrush, QColor,
QRadialGradient, QLinearGradient, QFont, QFontMetricsF,
QPixmap, QTransform, QShortcut, QKeySequence, QPolygonF)
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QScrollArea, QLineEdit, QScrollBar,
QSizePolicy, QFrame)
# ====================================================================================
# PALETTE -- locked by the art director. Warm near-black ink, never #000.
# ====================================================================================
INK = QColor("#140F08") # the one true outline
WALL_LIT = QColor("#C9B45A") # lit centre of the mustard wall
WALL_MID = QColor("#9A7E2E") # grimy falloff
WALL_DARK = QColor("#5B4410") # rotting vignette / cast shadow
FLESH_LIT = QColor("#F2B9A0") # lit fleshy pink
FLESH_SHADOW = QColor("#B5573F") # shadow flesh / jowl blush
BILE = QColor("#7FA62B") # sickly bile green
BILE_DARK = QColor("#3F5713") # bile shadow
BLOOD = QColor("#B11717") # dried-blood / lipstick red
BRUISE = QColor("#5B2A6E") # bruise purple
GOBLIN = QColor("#E8D24A") # mustard / diarrhea yellow (drool, sweat, snot)
SPEC = QColor("#FFF7E6") # the single hot specular blob
EYE_WHITE = QColor("#EFE7D2") # yellowed sclera
TOOTH = QColor("#D9C77A") # desaturated goblin for teeth
PAPER_LIT = QColor("#EFE6C8") # task card paper, lit
PAPER_SHADOW = QColor("#D8C99E") # task card paper, shadow
def col(c, a):
"""A copy of QColor c with alpha a."""
c2 = QColor(c)
c2.setAlpha(a)
return c2
# ====================================================================================
# HEARTBEAT -- ONE 50ms timer drives the whole app. self.t boils; tempo speeds it.
# ====================================================================================
class Clock(QObject):
tick = Signal()
def __init__(self):
super().__init__()
self.t = 0.0
self.tempo = 1.0
self._target_tempo = 1.0
self._timer = QTimer(self)
self._timer.setInterval(50) # 20fps, cartoon "on twos"
self._timer.timeout.connect(self._beat)
def start(self):
self._timer.start()
def set_tempo(self, target, snap=False):
self._target_tempo = target
if snap:
self.tempo = target
def _beat(self):
# ease tempo toward target so panic/idle transitions are smooth
self.tempo += (self._target_tempo - self.tempo) * 0.18
self.t += 0.05 * self.tempo
self.tick.emit()
CLOCK = Clock()
def noise(seed, t):
"""Deterministic pseudo-noise in [-1, 1] (the verified probe form)."""
x = math.sin(seed * 12.9898 + t * 0.13) * 43758.5453
return (x - math.floor(x)) * 2.0 - 1.0
def ink_pen(w=5.0, color=INK):
pen = QPen(color, w)
pen.setJoinStyle(Qt.RoundJoin)
pen.setCapStyle(Qt.RoundCap)
return pen
_FONT_FAMILIES = ["Impact", "Anton", "Arial Black",
"Liberation Sans Narrow", "DejaVu Sans"]
def heavy_font(size, italic=True, weight=QFont.Black):
f = QFont()
f.setFamilies(_FONT_FAMILIES)
f.setPixelSize(int(size))
f.setWeight(weight)
f.setItalic(italic)
f.setStyleStrategy(QFont.PreferOutline) # addText() needs outlines
return f
def plaque_font(size, weight=QFont.Bold):
"""The honest, legible font for actual task text."""
f = QFont()
f.setFamilies(["DejaVu Sans", "Liberation Sans", "Arial"])
f.setPixelSize(int(size))
f.setWeight(weight)
return f
# ====================================================================================
# PRIMITIVE: wobbly hand-inked path (lumpy quadrilateral, never a clean rect)
# ====================================================================================
def wobbly_path(rect, seed, t, segs=7, amp=4.0):
x0, y0, x1, y1 = rect.left(), rect.top(), rect.right(), rect.bottom()
def jit(i):
return amp * noise(seed + i * 7.7, t)
pts = []
n = segs
for i in range(n):
u = i / n
pts.append((x0 + u * (x1 - x0), y0 + jit(i)))
for i in range(n):
u = i / n
pts.append((x1 + jit(100 + i), y0 + u * (y1 - y0)))
for i in range(n):
u = i / n
pts.append((x1 - u * (x1 - x0), y1 + jit(200 + i)))
for i in range(n):
u = i / n
pts.append((x0 + jit(300 + i), y1 - u * (y1 - y0)))
path = QPainterPath()
path.moveTo(*pts[0])
for i in range(1, len(pts) + 1):
a = pts[i - 1]
b = pts[i % len(pts)]
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
path.quadTo(QPointF(*a), QPointF(*mid))
path.closeSubpath()
return path
def fill_then_ink(p, path, brush, ink_w=5.0):
"""House discipline: fill the gradient, THEN lay the thick ink on top."""
p.fillPath(path, brush)
p.setBrush(Qt.NoBrush)
p.setPen(ink_pen(ink_w))
p.drawPath(path)
# ====================================================================================
# PRIMITIVE: bulging veiny eyeball (reused for mascot, filter tabs, checkbox)
# ====================================================================================
def draw_eyeball(p, c, r, look=(0.0, 0.0), bloodshot=0.3, t=0.0, seed=1.0,
squash=1.0, lid=0.0, iris=BILE, iris_dark=BILE_DARK,
x_out=False, skin=FLESH_LIT):
r = max(2.0, float(r)) # a zero-radius QRadialGradient is UB -> crash
p.save()
ry = max(2.0, r * squash)
# sclera
g = QRadialGradient(c.x() - r * 0.3, c.y() - r * 0.3, r * 1.5)
g.setColorAt(0.0, SPEC)
g.setColorAt(0.55, EYE_WHITE)
g.setColorAt(1.0, QColor(196, 184, 150))
p.setBrush(QBrush(g))
p.setPen(ink_pen(max(2.0, r * 0.10)))
p.drawEllipse(c, r, ry)
# bloodshot veins crawling in from the rim
nv = min(8, int(2 + bloodshot * 9))
p.setPen(QPen(BLOOD, max(0.8, r * 0.04)))
for i in range(nv):
ang = i / max(1, nv) * math.tau + noise(seed + i, t) * 0.7
path = QPainterPath()
path.moveTo(c.x() + math.cos(ang) * r * 0.96, c.y() + math.sin(ang) * ry * 0.96)
for s in range(1, 5):
u = s / 4
rr = r * (1 - u * 0.55)
wob = noise(seed + i * 3 + s, t) * r * 0.12
path.lineTo(c.x() + math.cos(ang) * rr + wob,
c.y() + math.sin(ang) * (ry / r) * rr + wob)
p.drawPath(path)
# iris (rolls up dead when crossed out)
look_y = -0.45 if x_out else look[1]
ic = QPointF(c.x() + look[0] * r * 0.40, c.y() + look_y * ry * 0.40)
ig = QRadialGradient(ic, r * 0.46)
ig.setColorAt(0.0, iris.lighter(140))
ig.setColorAt(0.8, iris)
ig.setColorAt(1.0, iris_dark)
p.setBrush(QBrush(ig))
p.setPen(ink_pen(max(1.5, r * 0.07)))
p.drawEllipse(ic, r * 0.40, r * 0.40)
# pupil + glint
p.setBrush(QColor(8, 4, 2))
p.setPen(Qt.NoPen)
p.drawEllipse(ic, r * 0.17, r * 0.17)
p.setBrush(col(SPEC, 235))
p.drawEllipse(QPointF(ic.x() - r * 0.12, ic.y() - r * 0.12), r * 0.08, r * 0.08)
if x_out:
# done: a big wobbly dead X slashed across the whole eye
p.setPen(ink_pen(max(3.0, r * 0.15), BLOOD))
for dx in (-1, 1):
xp = QPainterPath()
xp.moveTo(c.x() - r * 0.6 * dx, c.y() - r * 0.6)
xp.quadTo(QPointF(c.x() + noise(seed, t) * 4, c.y()),
QPointF(c.x() + r * 0.6 * dx, c.y() + r * 0.6))
p.drawPath(xp)
# eyelids closing (blink)
if lid > 0.001:
lidcol = QColor(skin)
p.setPen(ink_pen(max(2.0, r * 0.09)))
p.setBrush(lidcol)
top = QPainterPath()
h = ry * lid
top.moveTo(c.x() - r * 1.05, c.y() - ry * 1.05)
top.lineTo(c.x() + r * 1.05, c.y() - ry * 1.05)
top.lineTo(c.x() + r * 1.05, c.y() - ry + h)
top.quadTo(QPointF(c.x(), c.y() - ry + h + ry * 0.25 * lid),
QPointF(c.x() - r * 1.05, c.y() - ry + h))
top.closeSubpath()
p.drawPath(top)
bot = QPainterPath()
bot.moveTo(c.x() - r * 1.05, c.y() + ry * 1.05)
bot.lineTo(c.x() + r * 1.05, c.y() + ry * 1.05)
bot.lineTo(c.x() + r * 1.05, c.y() + ry - h)
bot.quadTo(QPointF(c.x(), c.y() + ry - h - ry * 0.25 * lid),
QPointF(c.x() - r * 1.05, c.y() + ry - h))
bot.closeSubpath()
p.drawPath(bot)
p.restore()
class EyeBrain:
"""Idle blink + dart FSM, ticked off the heartbeat. Not a QPropertyAnimation
(there would be far too many)."""
def __init__(self, seed=0.0):
r = random.Random(seed * 1000 + 7)
self._r = r
self.look = [r.uniform(-0.4, 0.4), r.uniform(-0.3, 0.3)]
self.target = list(self.look)
self.retarget_at = r.uniform(0.5, 2.0)
self.next_blink = r.uniform(2.0, 5.0)
self.blinking = False
self.blink_start = 0.0
self.lid = 0.0
self.override = None # (dx,dy) to force gaze (hover/landed fly)
def update(self, t):
if t >= self.retarget_at:
self.target = [self._r.uniform(-0.5, 0.5), self._r.uniform(-0.4, 0.4)]
self.retarget_at = t + self._r.uniform(1.0, 3.0)
tgt = self.override if self.override else self.target
self.look[0] += (tgt[0] - self.look[0]) * 0.25
self.look[1] += (tgt[1] - self.look[1]) * 0.25
if not self.blinking and t >= self.next_blink:
self.blinking = True
self.blink_start = t
if self.blinking:
ph = (t - self.blink_start) / 0.18
if ph >= 1.0:
self.blinking = False
self.next_blink = t + self._r.uniform(2.0, 5.5)
self.lid = 0.0
else:
self.lid = math.sin(ph * math.pi) # snap shut, peel open
else:
self.lid = 0.0
# ====================================================================================
# PRIMITIVE: fake hand-lettering -- per-glyph jittered paths, ink shadow + outline
# ====================================================================================
def jitter_text(p, cx, baseline, text, size, t, seed=1.0,
fill_top=GOBLIN, fill_bot=FLESH_LIT, outline=INK,
jit_rot=7.0, jit_bob=5.0, shear=-0.18, center=True,
scale_punch=1.0, ink_w_factor=0.16):
"""Paint slammed-in title-card lettering. Returns total advance width."""
font = heavy_font(size)
fm = QFontMetricsF(font)
rng = random.Random(hash(text) & 0xffff) # stable per string
seeds = [rng.uniform(0, 1000) for _ in text]
glyphs = QPainterPath()
x = 0.0
for i, ch in enumerate(text):
gp = QPainterPath()
gp.addText(0, 0, font, ch)
s = (1.0 + noise(seeds[i], t) * 0.06) * scale_punch
rot = noise(seeds[i] + seed, t) * jit_rot
bob = noise(seeds[i] + seed + 30, t) * jit_bob
tr = QTransform()
tr.translate(x, bob)
tr.shear(shear, 0.0)
tr.rotate(rot)
tr.scale(s, s)
glyphs.addPath(tr.map(gp))
x += (fm.horizontalAdvance(ch) + size * 0.05) * s
total_w = x
ox = (cx - total_w / 2) if center else cx
place = QTransform().translate(ox, baseline)
word = place.map(glyphs)
p.save()
# hard ink shadow behind
shadow = QTransform().translate(4, 5).map(word)
p.fillPath(shadow, QBrush(INK))
# thick ink outline
p.strokePath(word, ink_pen(max(2.5, size * ink_w_factor), outline))
# gradient fill
br = word.boundingRect()
g = QLinearGradient(0, br.top(), 0, br.bottom())
g.setColorAt(0.0, fill_top)
g.setColorAt(1.0, fill_bot)
p.fillPath(word, QBrush(g))
p.restore()
return total_w
# ====================================================================================
# PRIMITIVE: glossy tempting red dome button face
# ====================================================================================
def draw_gloss_dome(p, rect, t, label, press=0.0, glow=0.0, enabled=True):
p.save()
cx = rect.center().x()
# volume-conserving squash from the press
sy = 1.0 - 0.22 * press
sx = 1.0 / math.sqrt(max(0.2, sy))
h = rect.height() * sy
w = rect.width() * sx
dome = QRectF(cx - w / 2, rect.bottom() - h, w, h)
# metal mount + cast shadow
mount = QRectF(rect.left() - 8, rect.bottom() - rect.height() * 0.32,
rect.width() + 16, rect.height() * 0.5)
p.setBrush(col(WALL_DARK, 110))
p.setPen(Qt.NoPen)
p.drawEllipse(mount.adjusted(6, 14, 6, 16))
fill_then_ink(p, wobbly_path(mount, 88, t, amp=3),
QBrush(QColor("#4a4a4a")), 5)
# rivets
for i in range(5):
rx = mount.left() + mount.width() * (0.12 + 0.18 * i)
ry = mount.center().y() + 6
p.setBrush(QColor("#2a2a2a")); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(rx, ry), 5, 5)
p.setBrush(col(SPEC, 200))
p.drawEllipse(QPointF(rx - 1.5, ry - 1.5), 1.8, 1.8)
# the dome
base = BLOOD if enabled else QColor("#7a5a58")
g = QRadialGradient(dome.center().x() - w * 0.18, dome.top() + h * 0.28, w * 0.75)
if enabled:
g.setColorAt(0.0, QColor(255, 110, 90))
g.setColorAt(0.5, QColor(220, 40, 34))
g.setColorAt(1.0, base)
else:
g.setColorAt(0.0, QColor(150, 120, 118))
g.setColorAt(1.0, QColor(90, 64, 62))
fill_then_ink(p, wobbly_path(dome, 42, t, amp=2.5), QBrush(g), 6)
# bottom bevel band blood->bruise
bev = QRectF(dome.left() + w * 0.08, dome.center().y() + h * 0.12,
w * 0.84, h * 0.34)
bg = QLinearGradient(0, bev.top(), 0, bev.bottom())
bg.setColorAt(0.0, col(BRUISE, 0)); bg.setColorAt(1.0, col(BRUISE, 150))
p.setPen(Qt.NoPen); p.setBrush(QBrush(bg))
p.drawRoundedRect(bev, h * 0.2, h * 0.2)
# hard gloss crescent (clip to dome)
p.save()
clip = QPainterPath(); clip.addEllipse(dome)
p.setClipPath(clip)
gloss = QRectF(dome.left() + w * 0.16, dome.top() + h * 0.05 + press * 4,
w * 0.68, h * 0.42)
hl = QRadialGradient(gloss.center(), gloss.width() * 0.6)
a = int((150 + 80 * glow) * (0.7 + 0.3 * (1 - press)))
hl.setColorAt(0.0, col(SPEC, min(255, a)))
hl.setColorAt(1.0, col(SPEC, 0))
p.setBrush(QBrush(hl)); p.setPen(Qt.NoPen)
p.drawEllipse(gloss)
# tiny hotspot ping
p.setBrush(col(SPEC, 230))
p.drawEllipse(QPointF(dome.left() + w * 0.32, dome.top() + h * 0.18), w * 0.05, h * 0.05)
p.restore()
# curved "DON'T PRESS!" arced over the dome
if label:
_arc_text(p, dome.adjusted(w * 0.05, h * 0.04, -w * 0.05, 0), label, t,
size=min(26, h * 0.34))
p.restore()
def _arc_text(p, rect, text, t, size):
"""Lay text along an arc over the top of rect."""
font = heavy_font(size)
fm = QFontMetricsF(font)
rng = random.Random(hash(text) & 0xfff)
cx, cy = rect.center().x(), rect.center().y() + rect.height() * 0.25
radius = rect.width() * 0.52
widths = [fm.horizontalAdvance(c) for c in text]
total = sum(widths)
# spread across an arc spanning ~140 degrees centred on top
span = math.radians(150)
p.save()
acc = 0.0
for i, ch in enumerate(text):
frac = (acc + widths[i] / 2) / total
acc += widths[i]
ang = -math.pi / 2 - span / 2 + span * frac # top arc
gx = cx + math.cos(ang) * radius
gy = cy + math.sin(ang) * radius * 0.78
gp = QPainterPath(); gp.addText(-widths[i] / 2, 0, font, ch)
tr = QTransform().translate(gx, gy)
tr.rotate(math.degrees(ang + math.pi / 2) + noise(i + 5, t) * 4)
word = tr.map(gp)
p.fillPath(QTransform().translate(2, 3).map(word), QBrush(INK))
p.strokePath(word, ink_pen(max(2.0, size * 0.16), INK))
p.fillPath(word, QBrush(GOBLIN))
p.restore()
# ====================================================================================
# MASCOTS -- Ren-like chihuahua (brand) + the Log + magic nose goblin
# ====================================================================================
def draw_dog_head(p, rect, t, brain, mood_eye=0.85):
"""Ren-like asthmatic chihuahua: egg head, satellite ears, heavy brow,
bug eyes, a long protruding muzzle and snaggle teeth. Sweating."""
p.save()
cx, cy = rect.center().x(), rect.center().y()
w, h = rect.width(), rect.height()
fur_lit, fur_mid, fur_dk = QColor(196, 166, 134), QColor(150, 118, 88), QColor(98, 72, 50)
# --- big pointed satellite ears (drawn behind the head) ---
for sx in (-1, 1):
wob = noise(sx * 9, t) * 5
ear = QPainterPath()
ear.moveTo(cx + sx * w * 0.12, cy - h * 0.30) # inner base, on crown
ear.quadTo(QPointF(cx + sx * w * 0.30, cy - h * 0.46),
QPointF(cx + sx * w * 0.52 + wob, cy - h * 0.40)) # tip, up & out
ear.quadTo(QPointF(cx + sx * w * 0.50, cy - h * 0.16),
QPointF(cx + sx * w * 0.30, cy - h * 0.02)) # outer base, low
ear.quadTo(QPointF(cx + sx * w * 0.20, cy - h * 0.14),
QPointF(cx + sx * w * 0.12, cy - h * 0.30))
eg = QLinearGradient(cx, cy - h * 0.4, cx + sx * w * 0.5, cy)
eg.setColorAt(0, fur_mid); eg.setColorAt(1, fur_dk)
fill_then_ink(p, ear, QBrush(eg), 4)
inner = QPainterPath()
inner.moveTo(cx + sx * w * 0.18, cy - h * 0.26)
inner.quadTo(QPointF(cx + sx * w * 0.38, cy - h * 0.34),
QPointF(cx + sx * w * 0.40, cy - h * 0.10))
p.setPen(QPen(FLESH_SHADOW, 3)); p.setBrush(Qt.NoBrush); p.drawPath(inner)
# --- egg/light-bulb head: narrow crown, fat cheeks, small chin ---
head = QPainterPath()
jit = lambda s: noise(s, t) * 3
head.moveTo(cx - w * 0.16, cy - h * 0.36 + jit(1))
head.cubicTo(cx - w * 0.08, cy - h * 0.46, cx + w * 0.08, cy - h * 0.46,
cx + w * 0.16, cy - h * 0.36 + jit(2)) # crown
head.cubicTo(cx + w * 0.40, cy - h * 0.26, cx + w * 0.42, cy + h * 0.10,
cx + w * 0.26, cy + h * 0.28 + jit(3)) # right cheek -> jaw
head.cubicTo(cx + w * 0.16, cy + h * 0.40, cx - w * 0.16, cy + h * 0.40,
cx - w * 0.26, cy + h * 0.28 + jit(4)) # chin
head.cubicTo(cx - w * 0.42, cy + h * 0.10, cx - w * 0.40, cy - h * 0.26,
cx - w * 0.16, cy - h * 0.36 + jit(5)) # left cheek
head.closeSubpath()
hg = QRadialGradient(cx - w * 0.12, cy - h * 0.20, w * 0.7)
hg.setColorAt(0, fur_lit); hg.setColorAt(0.7, fur_mid); hg.setColorAt(1, fur_dk)
fill_then_ink(p, head, QBrush(hg), 5)
# --- heavy lumpy brow ridge casting the eyes in shadow ---
brow = QPainterPath()
brow.moveTo(cx - w * 0.30, cy - h * 0.10)
brow.cubicTo(cx - w * 0.22, cy - h * 0.26, cx - w * 0.04, cy - h * 0.24,
cx, cy - h * 0.14)
brow.cubicTo(cx + w * 0.04, cy - h * 0.24, cx + w * 0.22, cy - h * 0.26,
cx + w * 0.30, cy - h * 0.10)
brow.cubicTo(cx + w * 0.18, cy - h * 0.14, cx - w * 0.18, cy - h * 0.14,
cx - w * 0.30, cy - h * 0.10)
brow.closeSubpath()
bg2 = QLinearGradient(0, cy - h * 0.26, 0, cy - h * 0.06)
bg2.setColorAt(0, fur_mid.lighter(108)); bg2.setColorAt(1, fur_dk)
fill_then_ink(p, brow, QBrush(bg2), 4)
# --- bug eyes, set close & high under the brow ---
er = w * 0.155
draw_eyeball(p, QPointF(cx - w * 0.13, cy - h * 0.04), er,
look=(brain.look[0], brain.look[1] + 0.1), bloodshot=mood_eye, t=t, seed=3,
lid=brain.lid, iris=QColor(120, 90, 60), iris_dark=QColor(40, 22, 10),
skin=fur_mid)
draw_eyeball(p, QPointF(cx + w * 0.15, cy - h * 0.03), er * 1.12,
look=(brain.look[0] * 0.7, brain.look[1] + 0.1), bloodshot=min(1, mood_eye + .1),
t=t, seed=8, lid=brain.lid, iris=QColor(120, 90, 60),
iris_dark=QColor(40, 22, 10), skin=fur_mid)
# --- protruding muzzle (the dog signal): a fat bump jutting down-front ---
muz = QPainterPath()
mcx, mtop = cx - w * 0.02, cy + h * 0.06
muz.addEllipse(QPointF(mcx, mtop + h * 0.16), w * 0.26, h * 0.22)
mg = QRadialGradient(mcx - w * 0.06, mtop + h * 0.06, w * 0.4)
mg.setColorAt(0, fur_lit.lighter(106)); mg.setColorAt(1, fur_mid)
fill_then_ink(p, muz, QBrush(mg), 5)
# big glossy black nose at the front-top of the muzzle
nose = QPainterPath()
nose.addEllipse(QPointF(mcx, mtop + h * 0.02), w * 0.12, h * 0.10)
p.setBrush(QColor(22, 14, 12)); p.setPen(ink_pen(3)); p.drawPath(nose)
p.setBrush(col(SPEC, 200)); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(mcx - w * 0.05, mtop - h * 0.01), w * 0.035, h * 0.03)
# nostrils
p.setBrush(QColor(0, 0, 0));
for sx in (-1, 1):
p.drawEllipse(QPointF(mcx + sx * w * 0.04, mtop + h * 0.04), w * 0.012, h * 0.018)
# snaggle mouth splitting down from under the nose + crooked teeth
p.setPen(ink_pen(4)); p.setBrush(Qt.NoBrush)
mouth = QPainterPath()
mouth.moveTo(mcx, mtop + h * 0.12)
mouth.lineTo(mcx, mtop + h * 0.22)
mouth.quadTo(QPointF(mcx - w * 0.16, mtop + h * 0.32),
QPointF(mcx - w * 0.22, mtop + h * 0.24))
mouth.moveTo(mcx, mtop + h * 0.22)
mouth.quadTo(QPointF(mcx + w * 0.16, mtop + h * 0.32),
QPointF(mcx + w * 0.22, mtop + h * 0.24))
p.drawPath(mouth)
for tx, tw in ((-0.10, 0.05), (0.04, 0.055)):
tooth = QPainterPath(); x = mcx + w * tx; y = mtop + h * 0.20
tooth.moveTo(x, y); tooth.lineTo(x + w * tw, y)
tooth.lineTo(x + w * tw * 0.5, y + h * 0.10); tooth.closeSubpath()
fill_then_ink(p, tooth, QBrush(TOOTH), 2.5)
# racing sweat bead off the brow
sweat_phase = (t * 0.6) % 3.0
if sweat_phase < 1.6:
sb = QPointF(cx + w * 0.30 + noise(2, t) * 2, cy - h * 0.24 + sweat_phase * h * 0.20)
_draw_drop(p, sb, 8)
p.restore()
def _draw_drop(p, top, size, color=GOBLIN):
drop = QPainterPath()
drop.moveTo(top)
drop.quadTo(QPointF(top.x() - size, top.y() + size * 1.4),
QPointF(top.x(), top.y() + size * 2.2))
drop.quadTo(QPointF(top.x() + size, top.y() + size * 1.4), top)
g = QRadialGradient(QPointF(top.x() - size * 0.3, top.y() + size), size * 1.6)
g.setColorAt(0, SPEC); g.setColorAt(0.5, color); g.setColorAt(1, color.darker(130))
fill_then_ink(p, drop, QBrush(g), 2)
def draw_log(p, rect, t):
"""It's Log! a fat brown wobbly cylinder, dumb and happy."""
p.save()
cx, cy = rect.center().x(), rect.center().y()
w, h = rect.width(), rect.height()
body = QRectF(cx - w * 0.42, cy - h * 0.20, w * 0.84, h * 0.40)
bg = QLinearGradient(0, body.top(), 0, body.bottom())
bg.setColorAt(0, QColor("#7A4A22")); bg.setColorAt(1, QColor("#4A2A12"))
fill_then_ink(p, wobbly_path(body, 55, t, amp=3), QBrush(bg), 5)
# cut end with rings
end = QRectF(body.right() - w * 0.16, body.top(), w * 0.22, body.height())
fill_then_ink(p, wobbly_path(end, 66, t, amp=2), QBrush(QColor("#8A5A2E")), 4)
p.setPen(QPen(QColor("#5A3A1A"), 2)); p.setBrush(Qt.NoBrush)
for k in range(1, 4):
rr = end.adjusted(end.width() * 0.5 * (1 - k / 4), end.height() * 0.5 * (1 - k / 4),
-end.width() * 0.5 * (1 - k / 4), -end.height() * 0.5 * (1 - k / 4))
p.drawEllipse(rr)
# knothole + dumb face
p.setBrush(QColor("#3A2410")); p.setPen(ink_pen(2))
p.drawEllipse(QPointF(cx - w * 0.12, cy - h * 0.02), w * 0.04, h * 0.05)
# eyes + smile
for sx in (-1, 1):
p.setBrush(QColor(20, 12, 6)); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(cx + sx * w * 0.10, cy - h * 0.06), 4, 4)
smile = QPainterPath()
smile.moveTo(cx - w * 0.08, cy + h * 0.02)
smile.quadTo(QPointF(cx, cy + h * 0.10), QPointF(cx + w * 0.08, cy + h * 0.02))
p.setPen(ink_pen(3)); p.setBrush(Qt.NoBrush); p.drawPath(smile)
# stink wisps
p.setPen(QPen(col(BILE, 150), 2))
for i in range(3):
wisp = QPainterPath()
bx = cx - w * 0.2 + i * w * 0.2
wisp.moveTo(bx, body.top())
for s in range(1, 4):
wisp.quadTo(QPointF(bx + (12 if s % 2 else -12), body.top() - s * 10 - 4),
QPointF(bx, body.top() - s * 12))
p.drawPath(wisp)
p.restore()
def draw_goblin(p, c, r, t, seed=0.0):
"""A magic nose goblin: lumpy green blob with two dumb eyes + snot shine."""
p.save()
blob = wobbly_path(QRectF(c.x() - r, c.y() - r, 2 * r, 2 * r), seed, t, segs=6, amp=r * 0.18)
g = QRadialGradient(c.x() - r * 0.3, c.y() - r * 0.3, r * 1.4)
g.setColorAt(0, BILE.lighter(140)); g.setColorAt(0.7, BILE); g.setColorAt(1, BILE_DARK)
fill_then_ink(p, blob, QBrush(g), max(1.5, r * 0.12))
for sx in (-1, 1):
p.setBrush(EYE_WHITE); p.setPen(ink_pen(max(1.0, r * 0.08)))
p.drawEllipse(QPointF(c.x() + sx * r * 0.3, c.y() - r * 0.1), r * 0.26, r * 0.30)
p.setBrush(QColor(10, 6, 3)); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(c.x() + sx * r * 0.3, c.y() - r * 0.05), r * 0.11, r * 0.11)
p.setBrush(col(SPEC, 220)); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(c.x() - r * 0.35, c.y() - r * 0.4), r * 0.14, r * 0.14)
p.restore()
# ====================================================================================
# BACKGROUND -- mustard wall + cached grime, painted by the Stage
# ====================================================================================
_GRIME_CACHE = {}
def grime_pixmap(w, h):
key = (w, h)
if key in _GRIME_CACHE:
return _GRIME_CACHE[key]
pm = QPixmap(w, h)
pm.fill(Qt.transparent)
pr = QPainter(pm)
pr.setRenderHint(QPainter.Antialiasing)
rng = random.Random(13) # stable grime, NOT TV static
for _ in range(60):
x = rng.uniform(0, w); y = rng.uniform(0, h)
rad = rng.uniform(6, 34)
a = rng.randint(8, 26)
cc = rng.choice([WALL_DARK, BILE_DARK, BRUISE, QColor("#3a2a06")])
pr.setBrush(col(cc, a)); pr.setPen(Qt.NoPen)
pr.drawEllipse(QPointF(x, y), rad, rad * rng.uniform(0.6, 1.2))
# a few drip streaks
pr.setPen(Qt.NoPen)
for _ in range(6):
x = rng.uniform(0, w); top = rng.uniform(0, h * 0.4)
length = rng.uniform(h * 0.15, h * 0.5)
wdt = rng.uniform(3, 7)
path = QPainterPath(); path.moveTo(x, top)
path.cubicTo(x + rng.uniform(-8, 8), top + length * 0.4,
x + rng.uniform(-8, 8), top + length * 0.7,
x, top + length)
pr.strokePath(path, QPen(col(WALL_DARK, 22), wdt))
# paper-tooth speckle
for _ in range(400):
x = rng.uniform(0, w); y = rng.uniform(0, h)
pr.setPen(QPen(col(WALL_DARK, rng.randint(8, 18)), 1))
pr.drawPoint(QPointF(x, y))
pr.end()
_GRIME_CACHE[key] = pm
return pm
def paint_wall(p, rect, t, frame=True):
"""Layers A (radial wall), B (grime), C (ink cel frame)."""
w, h = int(rect.width()), int(rect.height())
g = QRadialGradient(rect.left() + w * 0.42, rect.top() + h * 0.38, max(w, h) * 0.9)
# slow hue-crawl on the lit centre
lit = QColor(WALL_LIT)
lit = lit.lighter(int(100 + 6 * math.sin(t * 0.2)))
g.setColorAt(0.0, lit)
g.setColorAt(0.55, WALL_MID)
g.setColorAt(1.0, WALL_DARK)
p.fillRect(rect, QBrush(g))
p.drawPixmap(rect.topLeft(), grime_pixmap(w, h))
if frame:
inner = QRectF(rect.left() + 7, rect.top() + 7, w - 14, h - 14)
p.setBrush(Qt.NoBrush)
p.setPen(ink_pen(8))
p.drawPath(wobbly_path(inner, 999, t, segs=10, amp=3))
# ====================================================================================
# DATA -- Task dataclass + atomic, debounced JSON store
# ====================================================================================
SAVE_PATH = os.path.join(os.path.expanduser("~"), ".spumco_todo.json")
@dataclass
class Task:
text: str
done: bool = False
priority: int = 0
order: int = 0
created: float = field(default_factory=lambda: time.time())
id: str = field(default_factory=lambda: uuid.uuid4().hex)
class Store(QObject):
changed = Signal()
def __init__(self):
super().__init__()
self.tasks = []
self.filter = "all"
self._dirty = False
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(400) # debounce
self._save_timer.timeout.connect(self.flush)
self.load()
# ---- mutations -------------------------------------------------------------
def add(self, text):
text = text.strip()
if not text:
return None
order = (max((t.order for t in self.tasks), default=-1)) + 1
task = Task(text=text, order=order)
self.tasks.append(task)
self.touch()
return task
def remove(self, task):
if task in self.tasks:
self.tasks.remove(task)
self.touch()
def clear_done(self):
removed = [t for t in self.tasks if t.done]
self.tasks = [t for t in self.tasks if not t.done]
self.touch()
return removed
def move(self, task, delta):
ordered = self.ordered()
if task not in ordered:
return
i = ordered.index(task)
j = i + delta
if 0 <= j < len(ordered):
ordered[i], ordered[j] = ordered[j], ordered[i]
for k, tk in enumerate(ordered):
tk.order = k
self.touch()
def ordered(self):
return sorted(self.tasks, key=lambda t: t.order)
def active_count(self):
return sum(1 for t in self.tasks if not t.done)
def done_count(self):
return sum(1 for t in self.tasks if t.done)
def touch(self):
self._dirty = True
self._save_timer.start()
self.changed.emit()
# ---- persistence -----------------------------------------------------------
def load(self):
try:
with open(SAVE_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
self.filter = data.get("filter", "all")
self.tasks = []
for d in data.get("tasks", []):
self.tasks.append(Task(
text=str(d.get("text", "")),
done=bool(d.get("done", False)),
priority=int(d.get("priority", 0)),
order=int(d.get("order", 0)),
created=float(d.get("created", time.time())),
id=str(d.get("id", uuid.uuid4().hex)),
))
self.tasks.sort(key=lambda t: t.order)
except FileNotFoundError:
self.tasks = []
except Exception:
# corrupt: back it up, never crash
try:
shutil.copy(SAVE_PATH, SAVE_PATH + ".bak")
except Exception:
pass
self.tasks = []
def flush(self):
if not self._dirty:
return
data = {"version": 1, "filter": self.filter,
"tasks": [asdict(t) for t in self.ordered()]}
tmp = SAVE_PATH + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
os.replace(tmp, SAVE_PATH) # atomic
self._dirty = False
except Exception:
pass
# ====================================================================================
# TRANSPARENT BASE -- children float over the Stage's painted wall
# ====================================================================================
class Floating(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self.setAttribute(Qt.WA_NoSystemBackground, True)
self.t = 0.0
CLOCK.tick.connect(self._on_tick)
def _on_tick(self):
self.t = CLOCK.t
if self.isVisible():
self.update()
# ====================================================================================
# TITLE CARD -- brand lettering + Ren mascot + taunting speech bubble
# ====================================================================================
class TitleCard(Floating):
def __init__(self, store, parent=None):
super().__init__(parent)
self.store = store
self.brain = EyeBrain(seed=1.0)
self.setFixedHeight(126)
self._taunts = ["do it NOW!", "filthy...", "you EEDIOT", "so many chores",
"happy happy", "get to work!"]
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
self.brain.update(self.t)
w, h = self.width(), self.height()
# blood ribbon band with wavy edges
band = QPainterPath()
band.moveTo(0, h * 0.18)
band.cubicTo(w * 0.3, h * 0.10, w * 0.6, h * 0.26, w, h * 0.16)
band.lineTo(w, h * 0.80)
band.cubicTo(w * 0.6, h * 0.90, w * 0.3, h * 0.72, 0, h * 0.84)
band.closeSubpath()
bg = QLinearGradient(0, 0, 0, h)
bg.setColorAt(0, BLOOD.lighter(115)); bg.setColorAt(1, BLOOD.darker(135))
fill_then_ink(p, band, QBrush(bg), 5)
# title, centred in the band to the RIGHT of the mascot so it never clips
title_cx = 124 + (w - 14 - 124) / 2
jitter_text(p, title_cx, h * 0.50, "THE FILTHY LIST", 33, self.t, seed=2,
fill_top=GOBLIN, fill_bot=FLESH_LIT, center=True)
# Ren mascot bottom-left, breathing
breath = 1.0 + 0.02 * math.sin(self.t * 0.9)
p.save()
p.translate(62, h * 0.50)
p.scale(breath, breath)
draw_dog_head(p, QRectF(-54, -48, 108, 108), self.t, self.brain)
p.restore()
# taunting speech bubble, lower-right (clear of the title) with a down tail
idx = int(self.t * 0.25) % len(self._taunts)
bub = QRectF(w - 184, h - 50, 172, 40)
bubble = wobbly_path(bub, 71, self.t, segs=7, amp=3)
tail = QPainterPath()
tail.moveTo(bub.left() + 26, bub.bottom() - 4)
tail.lineTo(bub.left() + 8, bub.bottom() + 16)
tail.lineTo(bub.left() + 44, bub.bottom() - 4)
tail.closeSubpath()
fill_then_ink(p, tail, QBrush(EYE_WHITE), 4)
fill_then_ink(p, bubble, QBrush(EYE_WHITE), 4)
jitter_text(p, bub.center().x(), bub.center().y() + 6, self._taunts[idx], 15,
self.t, seed=idx + 3, fill_top=BLOOD, fill_bot=BLOOD.darker(120),
jit_rot=3, jit_bob=2)
p.end()
# ====================================================================================
# ADD BAR -- a drooling open mouth; the tongue is the ADD button
# ====================================================================================
class AddBar(Floating):
submitted = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(96)
self._tongue_press = 0.0
self._shake = 0.0
self.edit = QLineEdit(self)
self.edit.setFrame(False)
self.edit.setPlaceholderText("type yer filthy task...")
self.edit.setFont(plaque_font(18))
self.edit.setStyleSheet(
"QLineEdit{background:transparent;border:none;color:#241405;"
"selection-background-color:#B11717;selection-color:#FFF7E6;}")
self.edit.returnPressed.connect(self._fire)
def resizeEvent(self, e):
w, h = self.width(), self.height()
self.edit.setGeometry(int(w * 0.17), int(h * 0.52), int(w * 0.52), int(h * 0.30))
def _fire(self):
txt = self.edit.text().strip()
if not txt:
self.shake()
return
self.submitted.emit(txt)
self.edit.clear()
self.edit.setFocus()
def shake(self):
anim = QPropertyAnimation(self, b"shake", self)
anim.setDuration(360)
anim.setStartValue(1.0); anim.setEndValue(0.0)
anim.setEasingCurve(QEasingCurve.OutBounce)
anim.start(QPropertyAnimation.DeleteWhenStopped)
self._shake_anim = anim
def get_shake(self): return self._shake
def set_shake(self, v): self._shake = v; self.update()
shake = Property(float, get_shake, set_shake)
def mousePressEvent(self, e):
w, h = self.width(), self.height()
tongue = QRectF(w * 0.14, h * 0.46, w * 0.64, h * 0.46)
if tongue.contains(e.position()):
self._press_tongue()
self._fire()
super().mousePressEvent(e)
def _press_tongue(self):
a = QPropertyAnimation(self, b"tongue", self)
a.setDuration(220); a.setKeyValueAt(0, 0.0); a.setKeyValueAt(0.4, 1.0); a.setKeyValueAt(1, 0.0)
a.setEasingCurve(QEasingCurve.OutQuad)
a.start(QPropertyAnimation.DeleteWhenStopped)
self._tongue_anim = a
def get_tongue(self): return self._tongue_press
def set_tongue(self, v): self._tongue_press = v; self.update()
tongue = Property(float, get_tongue, set_tongue)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
if self._shake > 0.001:
p.translate(noise(7, self.t * 8) * 8 * self._shake, 0)
# lumpy fleshy mouth capsule
mouth = wobbly_path(QRectF(8, 6, w - 16, h - 12), 33, self.t, segs=9, amp=4)
mg = QLinearGradient(0, 0, 0, h)
mg.setColorAt(0, FLESH_LIT); mg.setColorAt(1, FLESH_SHADOW)
fill_then_ink(p, mouth, QBrush(mg), 6)
# gum line + yellowed teeth along the top inner edge
p.setPen(QPen(BLOOD.darker(120), 3))
gum = QPainterPath(); gum.moveTo(w * 0.10, h * 0.30)
gum.cubicTo(w * 0.4, h * 0.22, w * 0.6, h * 0.36, w * 0.90, h * 0.28)
p.setBrush(Qt.NoBrush); p.drawPath(gum)
nteeth = 8
for i in range(nteeth):
tx = w * (0.12 + 0.76 * i / (nteeth - 1))
tw_ = w * 0.07
tilt = noise(i * 4 + 1, self.t) * 6
p.save(); p.translate(tx, h * 0.30); p.rotate(tilt)
tooth = QPainterPath()
chip = (i == 3)
tooth.moveTo(-tw_ / 2, 0); tooth.lineTo(tw_ / 2, 0)
if chip:
tooth.lineTo(tw_ * 0.3, h * 0.12); tooth.lineTo(0, h * 0.07)
tooth.lineTo(-tw_ * 0.3, h * 0.14)
else:
tooth.lineTo(tw_ * 0.36, h * 0.15); tooth.lineTo(-tw_ * 0.36, h * 0.15)
tooth.closeSubpath()
fill_then_ink(p, tooth, QBrush(TOOTH), 2.5)
if i == 5: # one rotten tooth
p.setBrush(col(BRUISE, 180)); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(0, h * 0.09), tw_ * 0.18, tw_ * 0.18)
p.restore()
# the tongue = ADD button (curls when pressed)
curl = self._tongue_press
tongue = QRectF(w * 0.14, h * 0.46 + curl * 6, w * 0.64, h * 0.44 - curl * 8)
tg = QLinearGradient(0, tongue.top(), 0, tongue.bottom())
tg.setColorAt(0, FLESH_LIT.lighter(108)); tg.setColorAt(1, QColor("#C46A78"))
fill_then_ink(p, wobbly_path(tongue, 44, self.t, segs=7, amp=3), QBrush(tg), 5)
# central groove
p.setPen(QPen(FLESH_SHADOW.darker(115), 3)); p.setBrush(Qt.NoBrush)
gr = QPainterPath(); gr.moveTo(tongue.center().x(), tongue.top() + 6)
gr.quadTo(QPointF(tongue.center().x() + 6, tongue.center().y()),
QPointF(tongue.center().x(), tongue.bottom() - 6))
p.drawPath(gr)
# "ADD!" hint on the tongue if empty
if not self.edit.text():
jitter_text(p, tongue.right() - 30, tongue.center().y() + 7, "ADD!", 18,
self.t, seed=9, fill_top=BLOOD, fill_bot=BLOOD.darker(130),
jit_rot=5, jit_bob=2, center=True)
# drool strands when focused
if self.edit.hasFocus():
strand_len = 8 + abs(math.sin(self.t * 1.5)) * 14
for sx in (0.3, 0.62):
top = QPointF(w * sx, h * 0.36)
p.setPen(QPen(col(GOBLIN, 200), 3))
dp = QPainterPath(); dp.moveTo(top)
dp.cubicTo(top.x() - 4, top.y() + strand_len * 0.5,
top.x() + 4, top.y() + strand_len * 0.8,
top.x(), top.y() + strand_len)
p.setBrush(Qt.NoBrush); p.drawPath(dp)
p.setBrush(col(GOBLIN, 220)); p.setPen(ink_pen(1.5))
p.drawEllipse(QPointF(top.x(), top.y() + strand_len), 4, 5)
# blinking magic-nose-goblin caret near text end
if int(self.t * 2) % 2 == 0:
cx = self.edit.x() + min(self.edit.width() - 12,
QFontMetricsF(self.edit.font()).horizontalAdvance(self.edit.text()) + 14)
draw_goblin(p, QPointF(cx, self.edit.y() + self.edit.height() / 2), 10, self.t, seed=5)
p.end()
# ====================================================================================
# FILTER TABS -- three eyeball buttons (ALL / ACTIVE / FILTHY-DONE)
# ====================================================================================
class FilterTabs(Floating):
changed = Signal(str)
def __init__(self, store, parent=None):
super().__init__(parent)
self.store = store
self.setFixedHeight(72)
self._keys = ["all", "active", "done"]
self._labels = ["ALL", "ACTIVE", "FILTHY-DONE"]
self._brains = [EyeBrain(seed=10 + i) for i in range(3)]
self._dilate = [0.0, 0.0, 0.0]
self.setMouseTracking(True)
def _centers(self):
w, h = self.width(), self.height()
return [QPointF(w * (0.2 + 0.3 * i), h * 0.42) for i in range(3)]
def mousePressEvent(self, e):
for i, c in enumerate(self._centers()):
if (e.position() - c).manhattanLength() < 60:
self.select(self._keys[i])
return
super().mousePressEvent(e)
def select(self, key):
self.store.filter = key
self.store.touch()
self.changed.emit(key)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
r = 24
for i, c in enumerate(self._centers()):
self._brains[i].update(self.t)
sel = (self.store.filter == self._keys[i])
self._dilate[i] += ((1.0 if sel else 0.0) - self._dilate[i]) * 0.2
scale = 1.0 + 0.08 * self._dilate[i]
counts = {"all": len(self.store.tasks), "active": self.store.active_count(),
"done": self.store.done_count()}[self._keys[i]]
draw_eyeball(p, c, r * scale, look=self._brains[i].look,
bloodshot=0.3 + 0.5 * self._dilate[i], t=self.t, seed=10 + i,
lid=self._brains[i].lid,
iris=BILE if self._keys[i] != "done" else BLOOD,
iris_dark=BILE_DARK if self._keys[i] != "done" else BLOOD.darker())
# clean label: light halo + dark letters = legible on the mustard wall
label = f"{self._labels[i]} ({counts})"
lf = plaque_font(15, QFont.Black)
lpath = QPainterPath()
lpath.addText(0, 0, lf, label)
br = lpath.boundingRect()
place = QTransform().translate(c.x() - br.center().x(), c.y() + r + 26)
lp = place.map(lpath)
p.strokePath(lp, ink_pen(4.0, col(GOBLIN if sel else SPEC, 235))) # halo
p.fillPath(lp, QBrush(INK)) # letters
p.end()
# ====================================================================================
# TASK ITEM -- grotesque face-card row
# ====================================================================================
class TaskItem(Floating):
toggled = Signal(object)
deleted = Signal(object)
edited = Signal(object, str)
prioritized = Signal(object)
reordered = Signal(object, int)
def __init__(self, task, parent=None):
super().__init__(parent)
self.task = task
self.setFixedHeight(86)
self.setFocusPolicy(Qt.StrongFocus)
self.setMouseTracking(True)
self._seed = (hash(task.id) & 0xffff) / 7.0
self.brain = EyeBrain(seed=self._seed)
self._bulge = 0.0
self._pop = 0.0
self._hover_zone = None
self.edit = QLineEdit(self)
self.edit.setVisible(False)
self.edit.setFont(plaque_font(18))
self.edit.setStyleSheet(
"QLineEdit{background:#FFF7E6;border:2px solid #140F08;border-radius:4px;"
"color:#241405;padding:2px 6px;}")
self.edit.returnPressed.connect(self._commit_edit)
self.edit.editingFinished.connect(self._commit_edit)
# zones ---------------------------------------------------------------------
def _zones(self):
w, h = self.width(), self.height()
return {
"grab": QRectF(6, h * 0.2, 26, h * 0.6),
"check": QRectF(38, h * 0.18, 56, h * 0.64),
"text": QRectF(104, 4, w - 104 - 150, h - 8),
"prio": QRectF(w - 130, h * 0.24, 44, h * 0.52),
"del": QRectF(w - 70, h * 0.20, 60, h * 0.6),
}
def enterEvent(self, e):
self._animate_bulge(1.0); CLOCK.set_tempo(2.2)
def leaveEvent(self, e):
self._animate_bulge(0.0); self._hover_zone = None
self.brain.override = None
CLOCK.set_tempo(1.0)
def _animate_bulge(self, to):
a = QPropertyAnimation(self, b"bulge", self)
a.setDuration(150); a.setEndValue(to)
a.setEasingCurve(QEasingCurve.OutBack if to else QEasingCurve.InCubic)
a.start(QPropertyAnimation.DeleteWhenStopped); self._bulge_anim = a
def get_bulge(self): return self._bulge
def set_bulge(self, v): self._bulge = v; self.update()
bulge = Property(float, get_bulge, set_bulge)
def get_pop(self): return self._pop
def set_pop(self, v): self._pop = v; self.update()
pop = Property(float, get_pop, set_pop)
def pop_now(self):
a = QPropertyAnimation(self, b"pop", self)
a.setDuration(420); a.setKeyValueAt(0, 0.0); a.setKeyValueAt(0.4, 1.0); a.setKeyValueAt(1, 0.0)
a.setEasingCurve(QEasingCurve.OutBack)
a.start(QPropertyAnimation.DeleteWhenStopped); self._pop_anim = a
def mouseMoveEvent(self, e):
pos = e.position()
z = None
for name, rect in self._zones().items():
if rect.contains(pos):
z = name; break
self._hover_zone = z
# checkbox eye tracks the cursor
cz = self._zones()["check"]
dx = max(-0.6, min(0.6, (pos.x() - cz.center().x()) / 40))
dy = max(-0.6, min(0.6, (pos.y() - cz.center().y()) / 40))
self.brain.override = (dx, dy)
def mousePressEvent(self, e):
self.setFocus()
z = None
for name, rect in self._zones().items():
if rect.contains(e.position()):
z = name; break
if z == "check":
self.toggled.emit(self.task)
elif z == "del":
self.deleted.emit(self.task)
elif z == "prio":
self.prioritized.emit(self.task)
elif z == "text":
if e.type() == e.Type.MouseButtonDblClick:
self.start_edit()
def mouseDoubleClickEvent(self, e):
if self._zones()["text"].contains(e.position()):
self.start_edit()
# inline edit ----------------------------------------------------------------
def start_edit(self):
z = self._zones()["text"]
self.edit.setGeometry(int(z.left()), int(z.center().y() - 16), int(z.width()), 32)
self.edit.setText(self.task.text)
self.edit.setVisible(True)
self.edit.setFocus(); self.edit.selectAll()
def _commit_edit(self):
if not self.edit.isVisible():
return
txt = self.edit.text().strip()
self.edit.setVisible(False)
self.edited.emit(self.task, txt)
def keyPressEvent(self, e):
k = e.key()
if k == Qt.Key_Space:
self.toggled.emit(self.task)
elif k == Qt.Key_Delete or k == Qt.Key_Backspace:
self.deleted.emit(self.task)
elif k in (Qt.Key_F2, Qt.Key_Return, Qt.Key_Enter):
self.start_edit()
elif k in (Qt.Key_1, Qt.Key_2, Qt.Key_3):
self.prioritized.emit(self.task)
elif k == Qt.Key_Up and (e.modifiers() & Qt.AltModifier):
self.reordered.emit(self.task, -1)
elif k == Qt.Key_Down and (e.modifiers() & Qt.AltModifier):
self.reordered.emit(self.task, 1)
else:
super().keyPressEvent(e)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
self.brain.update(self.t)
w, h = self.width(), self.height()
breath = 1.0 + 0.02 * math.sin(self.t * 0.9 + self._seed)
sx = (1 + 0.12 * self._bulge) * (1 + 0.3 * self._pop)
sy = (1 - 0.06 * self._bulge) * (1 + 0.3 * self._pop)
p.save()
p.translate(w / 2, h / 2)
p.scale(sx * breath, sy * breath)
p.translate(-w / 2, -h / 2)
# double-inked lumpy card body, priority-tinted
body_rect = QRectF(4, 4, w - 8, h - 8)
bg = QLinearGradient(0, body_rect.top(), 0, body_rect.bottom())
if self.task.priority == 1:
bg.setColorAt(0, FLESH_LIT.lighter(110)); bg.setColorAt(1, PAPER_SHADOW)
elif self.task.priority == 2:
bg.setColorAt(0, QColor("#E9C0B6")); bg.setColorAt(1, QColor("#C99B8E"))
else:
bg.setColorAt(0, PAPER_LIT); bg.setColorAt(1, PAPER_SHADOW)
body = wobbly_path(body_rect, self._seed, self.t, segs=8, amp=5)
p.fillPath(body, QBrush(bg))
# double ink boil
p.setBrush(Qt.NoBrush)
p.setPen(ink_pen(5)); p.drawPath(body)
p.save()
p.translate(noise(self._seed, self.t) * 1.5, noise(self._seed + 5, self.t) * 1.5)
p.setPen(ink_pen(2.5, col(INK, 120))); p.drawPath(body); p.restore()
z = self._zones()
# (1) grab-wart handle
for k in range(3):
wc = QPointF(z["grab"].center().x(), z["grab"].top() + 6 + k * 12)
p.setBrush(FLESH_SHADOW); p.setPen(ink_pen(2))
p.drawEllipse(wc, 5 + noise(k, self.t), 4)
# (2) eyeball checkbox (follows cursor, crosses out when done)
cz = z["check"]
cb_squash = 1.0 + 0.25 * self._pop
draw_eyeball(p, cz.center(), 22 * cb_squash, look=self.brain.look,
bloodshot=0.95 if self.task.done else 0.3 + 0.4 * self._bulge,
t=self.t, seed=self._seed, lid=self.brain.lid,
iris=BILE, iris_dark=BILE_DARK, x_out=self.task.done)
if self.task.done:
_draw_drop(p, QPointF(cz.center().x() + 6, cz.center().y() + 16), 5)
# (3) task text plaque (real legible font), strike-through when done
if not self.edit.isVisible():
tz = z["text"]
p.save()
p.setFont(plaque_font(18))
if self.task.done:
p.setPen(BILE_DARK)
else:
p.setPen(INK)
fm = QFontMetricsF(p.font())
txt = fm.elidedText(self.task.text, Qt.ElideRight, int(tz.width()))
p.drawText(tz, Qt.AlignVCenter | Qt.AlignLeft, txt)
if self.task.done:
tw = fm.horizontalAdvance(txt)
strike = QPainterPath()
y = tz.center().y()
strike.moveTo(tz.left(), y)
strike.cubicTo(tz.left() + tw * 0.33, y - 4 + noise(1, self.t) * 2,
tz.left() + tw * 0.66, y + 4 + noise(2, self.t) * 2,
tz.left() + tw, y)
p.strokePath(strike, ink_pen(3.5, BLOOD))
p.restore()
# (4) priority blister (pulsing pustule)
pz = z["prio"]
throb = 1.0 + 0.12 * math.sin(self.t * (2 + self.task.priority * 2))
pr = (8 + self.task.priority * 4) * throb
pcol = [BILE, FLESH_LIT, BRUISE][self.task.priority]
pg = QRadialGradient(pz.center().x() - 3, pz.center().y() - 3, pr * 1.5)
pg.setColorAt(0, pcol.lighter(150)); pg.setColorAt(1, pcol.darker(120))
p.setBrush(QBrush(pg))
p.setPen(ink_pen(2 + self.task.priority + math.sin(self.t * 3)))
p.drawEllipse(pz.center(), pr, pr)
p.setBrush(col(SPEC, 200)); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(pz.center().x() - 3, pz.center().y() - 3), pr * 0.25, pr * 0.25)
if self.task.priority == 1:
_draw_drop(p, QPointF(pz.center().x() + pr, pz.top()), 4)
# (5) booger-flick delete
dz = z["del"]
flick = 1.0 + 0.15 * self._bulge if self._hover_zone == "del" else 1.0
# fingertip nub
p.setBrush(FLESH_LIT); p.setPen(ink_pen(3))
p.drawEllipse(dz.center(), 14 * flick, 16 * flick)
# nail
p.setBrush(QColor("#E8C9B0")); p.setPen(ink_pen(1.5))
nail = QPainterPath()
nail.moveTo(dz.center().x() - 6, dz.center().y() - 14)
nail.cubicTo(dz.center().x() + 8, dz.center().y() - 18,
dz.center().x() + 8, dz.center().y() - 6,
dz.center().x() - 4, dz.center().y() - 4)
p.drawPath(nail)
# booger blob on the tip
draw_goblin(p, QPointF(dz.center().x() + 2, dz.center().y() + 4), 7 * flick, self.t, seed=self._seed + 2)
p.restore()
p.end()
# ====================================================================================
# HISTORY ERASER BUTTON -- the centrepiece gag (footer)
# ====================================================================================
class EraserButton(Floating):
pressed_real = Signal()
pressed_empty = Signal()
def __init__(self, store, parent=None):
super().__init__(parent)
self.store = store
self.setFixedSize(230, 150)
self.setFocusPolicy(Qt.StrongFocus)
self._press = 0.0
self._cower = 0.0
self.setMouseTracking(True)
def _enabled(self):
return self.store.done_count() > 0
def get_press(self): return self._press
def set_press(self, v): self._press = v; self.update()
press = Property(float, get_press, set_press)
def mousePressEvent(self, e):
a = QPropertyAnimation(self, b"press", self)
a.setDuration(90); a.setStartValue(0.0); a.setEndValue(1.0)
a.setEasingCurve(QEasingCurve.OutQuad)
a.start(QPropertyAnimation.DeleteWhenStopped); self._down = a
def mouseReleaseEvent(self, e):
a = QPropertyAnimation(self, b"press", self)
a.setDuration(260); a.setStartValue(1.0); a.setEndValue(0.0)
a.setEasingCurve(QEasingCurve.OutElastic)
a.start(QPropertyAnimation.DeleteWhenStopped); self._up = a
if self._enabled():
self.pressed_real.emit()
else:
self.pressed_empty.emit()
def keyPressEvent(self, e):
if e.key() in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Space):
self.mousePressEvent(None); self.mouseReleaseEvent(None)
else:
super().keyPressEvent(e)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
en = self._enabled()
# idle temptation: gloss pulse + breathe
glow = (0.5 + 0.5 * math.sin(self.t * 2.0)) if en else 0.0
breathe = 1.0 + (0.03 * math.sin(self.t * 1.5) if en else 0.0)
p.save()
p.translate(w / 2, h)
p.scale(breathe, breathe)
p.translate(-w / 2, -h)
dome = QRectF(w * 0.12, h * 0.18, w * 0.76, h * 0.62)
draw_gloss_dome(p, dome, self.t, "DON'T PRESS!", press=self._press,
glow=glow, enabled=en)
p.restore()
# base label + warning sign
jitter_text(p, w / 2, h - 20, "HISTORY ERASER", 15, self.t, seed=4,
fill_top=GOBLIN if en else QColor("#9a8a55"),
fill_bot=FLESH_LIT if en else QColor("#8a7a55"),
jit_rot=3, jit_bob=2)
# tiny hint
p.setFont(plaque_font(11)); p.setPen(INK)
p.drawText(QRectF(0, h - 10, w, 12), Qt.AlignCenter, "do NOT press this.")
p.end()
# ====================================================================================
# COUNTER MEAT-TAG -- live active count
# ====================================================================================
class CounterTag(Floating):
def __init__(self, store, parent=None):
super().__init__(parent)
self.store = store
self.setFixedSize(290, 150)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
bob = math.sin(self.t * 1.1) * 4
n = self.store.active_count()
# tag body with punched hole + clipped corner
tag = QRectF(14, 30 + bob, w - 70, h - 80)
path = wobbly_path(tag, 77, self.t, segs=7, amp=3)
hole = QPainterPath(); hole.addEllipse(QPointF(tag.right() - 8, tag.top() + 12), 9, 9)
path = path.subtracted(hole)
tgrad = QLinearGradient(0, tag.top(), 0, tag.bottom())
tgrad.setColorAt(0, GOBLIN); tgrad.setColorAt(1, QColor("#8A6D1A"))
fill_then_ink(p, path, QBrush(tgrad), 5)
# blood smudge
p.setBrush(col(BLOOD, 90)); p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(tag.left() + 30, tag.center().y() + 6), 16, 10)
# string through the hole
p.setPen(ink_pen(2, QColor("#6b5510"))); p.setBrush(Qt.NoBrush)
st = QPainterPath(); st.moveTo(tag.right() - 8, tag.top() + 12)
st.cubicTo(tag.right() + 20, tag.top() - 6, tag.right() + 30, tag.top() + 20,
tag.right() + 44, tag.top() + 4)
p.drawPath(st)
# number color ramps with urgency
ncol = BILE if n <= 2 else (GOBLIN if n <= 6 else BLOOD)
jitter_text(p, tag.center().x() - 30, tag.center().y() + 12 + bob, str(n), 40,
self.t, seed=8, fill_top=ncol.lighter(120), fill_bot=ncol.darker(120),
jit_rot=8, jit_bob=4, center=True, scale_punch=1.0)
p.setFont(plaque_font(12, QFont.Black)); p.setPen(INK)
p.drawText(QRectF(tag.center().x() - 5, tag.center().y() - 14 + bob, 120, 50),
Qt.AlignVCenter | Qt.AlignLeft, "FILTHY\nCHORES\nLEFT, MAN!")
p.end()
# ====================================================================================
# EMPTY STATE -- It is Log!
# ====================================================================================
class EmptyState(Floating):
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(260)
self._goblin_x = 0.0
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
g = QRadialGradient(w / 2, h / 2, max(w, h) * 0.6)
g.setColorAt(0, QColor("#6E7A3A")); g.setColorAt(1, WALL_DARK)
p.fillRect(self.rect(), QBrush(g))
squash = 1.0 + 0.02 * math.sin(self.t * 1.2)
p.save(); p.translate(w / 2, h * 0.5); p.scale(1, squash)
draw_log(p, QRectF(-130, -50, 260, 100), self.t)
p.restore()
jitter_text(p, w / 2, h * 0.5 + 96, "No filthy tasks. For now.", 22, self.t,
seed=6, fill_top=GOBLIN, fill_bot=FLESH_LIT, jit_rot=4, jit_bob=3)
# a goblin drifts across
gx = (self.t * 40) % (w + 80) - 40
draw_goblin(p, QPointF(gx, h * 0.85 + math.sin(self.t * 3) * 8), 12, self.t, seed=3)
p.end()
# ====================================================================================
# CUSTOM VEIN SCROLLBAR
# ====================================================================================
class VeinScrollBar(QScrollBar):
def __init__(self, parent=None):
super().__init__(Qt.Vertical, parent)
self.setStyleSheet("background:transparent;")
self.setFixedWidth(22)
self.t = 0.0
CLOCK.tick.connect(self._tk)
def _tk(self):
self.t = CLOCK.t
if self.isVisible():
self.update()
def _handle_rect(self):
rng = self.maximum() - self.minimum()
if rng <= 0:
return None
groove_h = self.height() - 12
page = self.pageStep()
frac = page / (rng + page)
hh = max(40, groove_h * frac)
pos = (self.value() - self.minimum()) / rng
y = 6 + pos * (groove_h - hh)
return QRectF(4, y, self.width() - 8, hh)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
# track
track = QRectF(5, 6, w - 10, h - 12)
tg = QLinearGradient(0, 0, 0, h)
tg.setColorAt(0, FLESH_SHADOW); tg.setColorAt(1, QColor("#3A1810"))
fill_then_ink(p, wobbly_path(track, 31, self.t, segs=6, amp=2), QBrush(tg), 3)
hr = self._handle_rect()
if hr:
breathe = 1.0 + 0.10 * math.sin(self.t * 2.5)
hr2 = QRectF(hr.center().x() - hr.width() / 2 * breathe, hr.top(),
hr.width() * breathe, hr.height())
vg = QLinearGradient(0, hr2.top(), 0, hr2.bottom())
vg.setColorAt(0, BRUISE); vg.setColorAt(1, BLOOD)
fill_then_ink(p, wobbly_path(hr2, 41, self.t, segs=5, amp=2), QBrush(vg), 3)
# gloss streak
p.setPen(QPen(col(SPEC, 140), 2)); p.setBrush(Qt.NoBrush)
p.drawLine(QPointF(hr2.left() + 4, hr2.top() + 6),
QPointF(hr2.left() + 4, hr2.bottom() - 6))
p.end()
# ====================================================================================
# OVERLAYS -- click-through reaction shots + confetti
# ====================================================================================
class ReactionOverlay(Floating):
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self.hide()
self._phase = 0.0
self._kind = None
self._anim = None
self._brain = EyeBrain(seed=99)
def play(self, kind):
if self._anim is not None: # guard re-entrancy: reuse one anim
self._anim.stop()
self._kind = kind
self.setGeometry(self.parent().rect())
self.show(); self.raise_()
dur = 1300 if kind == "eediot" else 1600
a = self._anim = QPropertyAnimation(self, b"phase", self)
a.setDuration(dur); a.setStartValue(0.0); a.setEndValue(1.0)
a.setEasingCurve(QEasingCurve.Linear)
a.finished.connect(self._done)
a.start()
CLOCK.set_tempo(6.0)
def _done(self):
self.hide()
CLOCK.set_tempo(1.0) # restore pace here, not via a stray timer
def get_phase(self): return self._phase
def set_phase(self, v): self._phase = v; self.update()
phase = Property(float, get_phase, set_phase)
def paintEvent(self, e):
if self._kind is None:
return
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
ph = self._phase
self._brain.update(self.t)
if self._kind == "eediot":
# smash-zoom furious bloodshot eye
zoom = min(1.0, ph / 0.1) if ph < 0.1 else (1.0 if ph < 0.7 else max(0.0, 1 - (ph - 0.7) / 0.3))
p.save()
p.translate(noise(1, self.t * 6) * 8 * zoom, noise(2, self.t * 6) * 5 * zoom)
p.fillRect(self.rect(), col(QColor(20, 0, 0), int(150 * zoom)))
# bruise vignette
vg = QRadialGradient(w / 2, h / 2, max(w, h) * 0.7)
vg.setColorAt(0, col(BRUISE, 0)); vg.setColorAt(1, col(QColor(10, 0, 0), int(210 * zoom)))
p.fillRect(self.rect(), QBrush(vg))
er = max(3.0, min(w, h) * 0.36 * zoom)
pupil = 0.1 if 0.2 < ph < 0.6 else 0.25
self._brain.override = (math.sin(self.t * 5) * 0.2, 0)
draw_eyeball(p, QPointF(w / 2, h / 2), er, look=self._brain.look,
bloodshot=1.0, t=self.t * 1.5, seed=42, lid=0.0,
iris=BLOOD, iris_dark=QColor(60, 0, 0))
# shock rings
for k in range(3):
rp = (ph * 3 - k * 0.2)
if 0 < rp < 1:
rr = er * (1 + rp * 1.5)
p.setPen(QPen(col(SPEC, int(180 * (1 - rp))), 4)); p.setBrush(Qt.NoBrush)
p.drawEllipse(QPointF(w / 2, h / 2), rr, rr)
if 0.1 < ph < 0.9:
jitter_text(p, w / 2, h * 0.86, "YOU EEDIOT!", 60, self.t, seed=7,
fill_top=BLOOD.lighter(120), fill_bot=BLOOD.darker(120),
jit_rot=8, jit_bob=6, scale_punch=min(1.2, 0.5 + ph))
p.restore()
elif self._kind == "joy":
# HAPPY HAPPY JOY JOY banner bouncing in, dancing cat
p.fillRect(self.rect(), col(BILE, int(40 * (1 - abs(ph - 0.5) * 2))))
by = -120 + (1 - max(0, 1 - ph / 0.2)) * 0 # ease via OutBounce-ish
bounce = h * 0.32 + abs(math.sin(self.t * 6)) * -10
scale = min(1.3, 0.5 + ph * 2) if ph < 0.4 else 1.0
jitter_text(p, w / 2, bounce, "HAPPY HAPPY", 48, self.t, seed=11,
fill_top=GOBLIN, fill_bot=BILE, jit_rot=7, jit_bob=8,
scale_punch=scale)
jitter_text(p, w / 2, bounce + 70, "JOY JOY!", 48, self.t, seed=12,
fill_top=FLESH_LIT, fill_bot=BLOOD, jit_rot=7, jit_bob=8,
scale_punch=scale)
p.end()
class Particle:
__slots__ = ("x", "y", "vx", "vy", "ang", "spin", "kind", "color", "size")
def __init__(self, x, y, rng):
self.x = x; self.y = y
self.vx = rng.uniform(-6, 6); self.vy = rng.uniform(-14, -4)
self.ang = rng.uniform(0, 360); self.spin = rng.uniform(-18, 18)
self.kind = rng.choice(["rect", "tri", "heart", "goblin"])
self.color = rng.choice([GOBLIN, FLESH_LIT, BILE, BLOOD, BRUISE])
self.size = rng.uniform(7, 15)
class ConfettiOverlay(Floating):
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self.hide()
self._parts = []
self._rng = random.Random(7)
self._life = 0
def burst(self, n=30):
self.setGeometry(self.parent().rect())
w = self.width()
self._parts = [Particle(self._rng.uniform(w * 0.3, w * 0.7),
self.height() * 0.5, self._rng) for _ in range(n)]
self._life = 70
self.show(); self.raise_()
def _on_tick(self):
super()._on_tick()
if not self.isVisible():
return
self._life -= 1
for pt in self._parts:
pt.vy += 0.55
pt.vx *= 0.99
pt.x += pt.vx; pt.y += pt.vy
pt.ang += pt.spin
if self._life <= 0:
self.hide(); self._parts = []
def paintEvent(self, e):
if not self._parts:
return
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
for pt in self._parts:
p.save()
p.translate(pt.x, pt.y); p.rotate(pt.ang)
if pt.kind == "goblin":
draw_goblin(p, QPointF(0, 0), pt.size * 0.8, self.t, seed=pt.x)
else:
p.setBrush(pt.color); p.setPen(ink_pen(1.5))
if pt.kind == "rect":
p.drawRoundedRect(QRectF(-pt.size / 2, -pt.size / 3, pt.size, pt.size * 0.66), 2, 2)
elif pt.kind == "tri":
tri = QPolygonF([QPointF(0, -pt.size / 2), QPointF(pt.size / 2, pt.size / 2),
QPointF(-pt.size / 2, pt.size / 2)])
p.drawPolygon(tri)
else: # heart
hp = QPainterPath(); s = pt.size / 2
hp.moveTo(0, s * 0.6)
hp.cubicTo(-s * 1.2, -s * 0.4, -s * 0.4, -s * 1.1, 0, -s * 0.4)
hp.cubicTo(s * 0.4, -s * 1.1, s * 1.2, -s * 0.4, 0, s * 0.6)
p.drawPath(hp)
p.restore()
p.end()
# ====================================================================================
# STAGE -- paints the wall behind everything
# ====================================================================================
class Stage(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.t = 0.0
CLOCK.tick.connect(self._tk)
def _tk(self):
self.t = CLOCK.t
self.update()
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
paint_wall(p, QRectF(self.rect()), self.t, frame=True)
p.end()
# ====================================================================================
# MAIN WINDOW
# ====================================================================================
class FilthyList(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("THE FILTHY LIST")
self.resize(640, 780)
self.setMinimumSize(520, 600)
self.store = Store()
stage = Stage(self)
self.setCentralWidget(stage)
root = QVBoxLayout(stage)
root.setContentsMargins(16, 14, 16, 14)
root.setSpacing(8)
self.title = TitleCard(self.store)
self.addbar = AddBar()
self.tabs = FilterTabs(self.store)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.NoFrame)
self.scroll.setVerticalScrollBar(VeinScrollBar())
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll.setStyleSheet("QScrollArea{background:transparent;}"
"QScrollArea>QWidget>QWidget{background:transparent;}")
self.scroll.viewport().setAutoFillBackground(False)
self.list_host = QWidget()
self.list_host.setAttribute(Qt.WA_TranslucentBackground, True)
self.vbox = QVBoxLayout(self.list_host)
self.vbox.setContentsMargins(6, 6, 6, 6)
self.vbox.setSpacing(6)
self.vbox.addStretch(1)
self.scroll.setWidget(self.list_host)
self.empty = EmptyState()
# footer
footer = QWidget()
footer.setAttribute(Qt.WA_TranslucentBackground, True)
fl = QHBoxLayout(footer)
fl.setContentsMargins(0, 0, 0, 0)
self.counter = CounterTag(self.store)
self.eraser = EraserButton(self.store)
fl.addWidget(self.counter, 1)
fl.addStretch(0)
fl.addWidget(self.eraser, 0)
root.addWidget(self.title)
root.addWidget(self.addbar)
root.addWidget(self.tabs)
root.addWidget(self.scroll, 1)
root.addWidget(self.empty)
root.addWidget(footer)
# overlays (children of the window, click-through)
self.reaction = ReactionOverlay(self)
self.confetti = ConfettiOverlay(self)
# wiring
self.addbar.submitted.connect(self.on_add)
self.tabs.changed.connect(lambda *_: self.rebuild())
self.eraser.pressed_real.connect(self.on_clear_done)
self.eraser.pressed_empty.connect(self.on_eediot)
# shortcuts
QShortcut(QKeySequence("Ctrl+1"), self, lambda: self.tabs.select("all"))
QShortcut(QKeySequence("Ctrl+2"), self, lambda: self.tabs.select("active"))
QShortcut(QKeySequence("Ctrl+3"), self, lambda: self.tabs.select("done"))
QShortcut(QKeySequence("Ctrl+E"), self, self._erase_shortcut)
self._items = {}
self.rebuild()
self.addbar.edit.setFocus()
CLOCK.start()
# ---- list management -------------------------------------------------------
def _visible(self, task):
f = self.store.filter
if f == "active":
return not task.done
if f == "done":
return task.done
return True
def rebuild(self):
# remove existing item widgets
while self.vbox.count() > 1:
it = self.vbox.takeAt(0)
wgt = it.widget()
if wgt:
wgt.setParent(None); wgt.deleteLater()
self._items.clear()
visible_tasks = [t for t in self.store.ordered() if self._visible(t)]
has_any = len(self.store.tasks) > 0
self.empty.setVisible(not has_any)
self.scroll.setVisible(has_any)
for i, task in enumerate(visible_tasks):
item = TaskItem(task)
item.toggled.connect(self.on_toggle)
item.deleted.connect(self.on_delete)
item.edited.connect(self.on_edit)
item.prioritized.connect(self.on_priority)
item.reordered.connect(self.on_reorder)
self.vbox.insertWidget(i, item)
self._items[task.id] = item
self.counter.update()
self.tabs.update()
self.eraser.update()
# ---- actions ---------------------------------------------------------------
def on_add(self, text):
self.store.add(text)
self.rebuild()
def on_toggle(self, task):
task.done = not task.done
self.store.touch()
if task.done:
item = self._items.get(task.id)
if item:
item.pop_now()
self.celebrate()
if self.store.active_count() == 0 and self.store.tasks:
pass # all done -> the celebration says it all
# respect filter: a toggled task may vanish under active/done filters
if self.store.filter in ("active", "done"):
QTimer.singleShot(650, self.rebuild)
else:
self.counter.update(); self.tabs.update(); self.eraser.update()
def on_delete(self, task):
self.store.remove(task)
self.eediot()
self.rebuild()
def on_edit(self, task, text):
if not text:
self.store.remove(task)
self.eediot()
else:
task.text = text
self.store.touch()
self.rebuild()
def on_priority(self, task):
task.priority = (task.priority + 1) % 3
self.store.touch()
if task.priority == 2:
self.reaction.play("eediot")
item = self._items.get(task.id)
if item:
item.update()
def on_reorder(self, task, delta):
self.store.move(task, delta)
self.rebuild()
item = self._items.get(task.id)
if item:
item.setFocus()
def on_clear_done(self):
# white flash + vacuum, then remove
self.confetti.burst(16)
self.store.clear_done()
QTimer.singleShot(120, self.rebuild)
def _erase_shortcut(self):
if self.store.done_count() > 0:
self.on_clear_done()
else:
self.on_eediot()
def on_eediot(self):
self.reaction.play("eediot")
def celebrate(self):
self.reaction.play("joy")
self.confetti.burst(30)
def eediot(self):
self.reaction.play("eediot")
# ---- overlays track resize -------------------------------------------------
def resizeEvent(self, e):
self.reaction.setGeometry(self.rect())
self.confetti.setGeometry(self.rect())
super().resizeEvent(e)
def closeEvent(self, e):
CLOCK._timer.stop() # don't tick a dying window
self.reaction.hide()
self.confetti.hide()
self.store.flush() # synchronous final save
super().closeEvent(e)
def main():
app = QApplication(sys.argv)
app.setApplicationName("THE FILTHY LIST")
win = FilthyList()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment