-
-
Save coleifer/cf1215acc460382ae75ac9945c0309b2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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