Skip to content

Instantly share code, notes, and snippets.

@coleifer
Created June 19, 2026 03:06
Show Gist options
  • Select an option

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

Select an option

Save coleifer/499add15d547e7a935b0290ebec9f66d to your computer and use it in GitHub Desktop.
Sally's vision
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
THE QUACKADERO — a to-do list designed by Sally Cruikshank.
A single-module PySide6 application. The whole window is one glowing Art-Deco
amusement-park marquee: a barber-pole candy awning, a ring of chasing light
bulbs, a rotating sunburst behind hand-built balloon lettering, and a sunset
sky that slowly breathes its hue. Each task is a cream admission TICKET with a
torn-stub edge and a tiny grinning anthropomorphic checkbox that watches its
words. Quasi -- the pear-bodied, caped, bespectacled duck -- greets you from
the ticket booth, blinking and bobbing, squash-stretching with glee when you
complete a ride and ducking sheepishly when you tear one up.
Nothing sits still: outlines boil, cards breathe on a staggered sine, the awning
scrolls, the bulbs chase. Underneath the vaudeville it is a real to-do list:
add / complete / edit / delete (with undo) / filter / live tally / persistence.
A Calm Mode freezes all idle motion for accessibility.
Every pixel is painted in code with QPainter -- there are no image assets and no
bundled fonts; the marquee wordmark is rendered glyph-by-glyph as outlined paths
so it keeps its candy bevel on any font.
Run: python quackadero.py
Screenshot: python quackadero.py --screenshot out.png (renders sample states)
"""
import sys
import os
import json
import math
import random
from dataclasses import dataclass, asdict
from PySide6.QtCore import (
Qt, QTimer, QPoint, QPointF, QRectF, Signal, QObject, Property,
QPropertyAnimation, QEasingCurve, QStandardPaths,
)
from PySide6.QtGui import (
QPainter, QColor, QPainterPath, QPen, QBrush, QFont, QFontMetricsF,
QLinearGradient, QRadialGradient, QPolygonF, QPixmap, QCursor, QFontDatabase,
QGuiApplication, QKeySequence, QShortcut,
)
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
QScrollArea, QFrame, QSizePolicy, QPushButton,
)
# =============================================================================
# PALETTE -- Sally's candy-Deco colours. Never pure #000 / #fff.
# =============================================================================
class C:
SKY_TOP = QColor("#FFD7B0") # warm dawn band
SKY_MID = QColor("#B58BD6") # lavender haze
GRAPE = QColor("#6A3FA0") # marquee frame / deep ground
PINK = QColor("#F25C9E") # Quasi pink -- the brand colour
MAGENTA = QColor("#E0218A") # pressed / hot clash
TANGERINE = QColor("#FF8A3D") # sunburst rays, drips
MARIGOLD = QColor("#FFC233") # marquee bulbs, the duck's body
CYAN = QColor("#1FC8E0") # electric counter-accent
TEAL = QColor("#1B8C8C") # footer strip, dimmed done-text
MINT = QColor("#7BD389") # polka dots, satisfied checkbox
CREAM = QColor("#FBF3DC") # every card surface
INK = QColor("#231F20") # all outlines & text (warm near-black)
RED = QColor("#D8362F") # delete, the cape, the RIDDEN! stamp
CHROME_HI = QColor("#B5C4CC") # polished-chrome specular
CHROME_LO = QColor("#7E8C94") # polished-chrome shadow
# =============================================================================
# SHARED CLOCK -- ONE timer drives every idle animation in the whole app.
# =============================================================================
class Clock(QObject):
"""A single ~50fps heartbeat. Widgets connect tick->update and read .phase
(seconds). Calm Mode zeroes the motion factor so all idle wobble freezes."""
tick = Signal()
def __init__(self, fps=50, parent=None):
super().__init__(parent)
self.phase = 0.0
self.calm = False
self._dt = 1.0 / fps
self._timer = QTimer(self)
self._timer.timeout.connect(self._on_tick)
self._timer.start(int(1000 * self._dt))
def _on_tick(self):
self.phase += self._dt
self.tick.emit()
def motion(self):
"""0.0 when calm (freeze idle motion), else 1.0."""
return 0.0 if self.calm else 1.0
def bucket(self):
"""Integer that changes ~8x/sec -- the reseed clock for boiling ink."""
return int(self.phase * 8.0)
# The clock is a module-global singleton, created in main() before any widget.
clock: "Clock" = None # type: ignore
# =============================================================================
# MATH / DRAWING HELPERS
# =============================================================================
def lerp(a, b, t):
return a + (b - a) * t
def lerp_color(c1, c2, t):
t = max(0.0, min(1.0, t))
return QColor(
int(lerp(c1.red(), c2.red(), t)),
int(lerp(c1.green(), c2.green(), t)),
int(lerp(c1.blue(), c2.blue(), t)),
int(lerp(c1.alpha(), c2.alpha(), t)),
)
def with_alpha(color, a):
c = QColor(color)
c.setAlpha(int(a))
return c
def _hash01(*xs):
"""Deterministic value-noise in [0,1) from a handful of numbers."""
s = 0.0
for x, k in zip(xs, (12.9898, 78.233, 37.719, 93.989, 5.137, 41.21)):
s += x * k
h = math.sin(s) * 43758.5453
return h - math.floor(h)
def boil_path(base: QPainterPath, amp, seed, bucket, samples=72):
"""Resample a path's perimeter, jitter each sample by value-noise, and
rebuild it as a smooth closed curve. This is the 'boiling cel outline' that
every contour in the app shares. amp<=0 returns the crisp original."""
if amp <= 0:
return base
length = base.length()
if length <= 0.0:
return base
pts = []
for i in range(samples):
pt = base.pointAtPercent(i / samples)
dx = (_hash01(seed, i, bucket, 1.0) * 2 - 1) * amp
dy = (_hash01(seed, i, bucket, 2.0) * 2 - 1) * amp
pts.append(QPointF(pt.x() + dx, pt.y() + dy))
out = QPainterPath()
out.moveTo((pts[-1] + pts[0]) / 2)
for i in range(samples):
cur = pts[i]
nxt = pts[(i + 1) % samples]
out.quadTo(cur, (cur + nxt) / 2)
out.closeSubpath()
return out
class BoilMixin:
"""Caches boiled paths per widget so the 72-sample resample only runs on the
~8Hz reseed boundary, not every frame."""
def boiled(self, key, base, amp):
amp = amp * clock.motion()
store = self.__dict__.setdefault("_boil_store", {})
b = clock.bucket()
sz = (round(base.boundingRect().width()), round(base.boundingRect().height()))
cached = store.get(key)
if cached and cached[0] == b and cached[1] == sz and cached[2] == (amp > 0):
return cached[3]
seed = (abs(hash(key)) % 9973) + 1
path = boil_path(base, amp, seed, b)
store[key] = (b, sz, amp > 0, path)
return path
def rounded_path(rect: QRectF, radius):
p = QPainterPath()
p.addRoundedRect(rect, radius, radius)
return p
def ticket_path(rect: QRectF, radius=14, notch=10):
"""A rounded card with two semicircle stub-notches bitten out of its sides."""
base = rounded_path(rect, radius)
nl = QPainterPath(); nl.addEllipse(QPointF(rect.left(), rect.center().y()), notch, notch)
nr = QPainterPath(); nr.addEllipse(QPointF(rect.right(), rect.center().y()), notch, notch)
return base.subtracted(nl).subtracted(nr)
def draw_googly(p: QPainter, cx, cy, r, look=QPointF(0, 0), blink=0.0,
sclera=None, rim=2.5):
if sclera is None:
sclera = C.CREAM
"""A white eye with an ink pupil that looks toward `look` (a clamped unit-ish
offset); blink in [0,1] squashes it shut."""
h = r * (1.0 - 0.92 * blink)
p.setPen(QPen(C.INK, rim)); p.setBrush(QColor(sclera))
p.drawEllipse(QPointF(cx, cy), r, max(0.6, h))
if blink < 0.6:
pr = r * 0.42
px = cx + look.x() * (r - pr) * 0.8
py = cy + look.y() * (max(0.6, h) - pr) * 0.8
p.setPen(Qt.NoPen); p.setBrush(C.INK)
p.drawEllipse(QPointF(px, py), pr, pr * (h / r))
p.setBrush(with_alpha(C.CREAM, 235))
p.drawEllipse(QPointF(px - pr * 0.3, py - pr * 0.35), pr * 0.3, pr * 0.3)
def starburst_polygon(n, r_in, r_out, rot=0.0):
"""A spiky star/sunburst as one polygon (alternating in/out radii)."""
poly = QPolygonF()
for i in range(n * 2):
ang = rot + i * math.pi / n
rad = r_out if i % 2 == 0 else r_in
poly.append(QPointF(math.cos(ang) * rad, math.sin(ang) * rad))
return poly
def eye_look(widget, anchor: QPointF, reach=1.0):
"""A clamped unit-ish vector from a widget-local anchor toward the cursor."""
g = widget.mapFromGlobal(QCursor.pos())
dx, dy = g.x() - anchor.x(), g.y() - anchor.y()
d = math.hypot(dx, dy)
if d < 1e-3:
return QPointF(0, 0)
f = min(1.0, d / 60.0) * reach
return QPointF(dx / d * f, dy / d * f)
# ---- cached repeating brush tiles (built once, rebuilt only on demand) -------
_tile_cache = {}
def stripe_tile(c1, c2, w=13):
key = ("stripe", c1.rgba(), c2.rgba(), w)
if key not in _tile_cache:
size = w * 2
pm = QPixmap(size, size)
pm.fill(c1)
pr = QPainter(pm)
pr.setRenderHint(QPainter.Antialiasing)
pr.setPen(Qt.NoPen); pr.setBrush(c2)
# one diagonal band that tiles seamlessly across the square
poly = QPolygonF([QPointF(0, size), QPointF(w, size),
QPointF(size, w), QPointF(size, 0)])
pr.drawPolygon(poly)
pr.drawPolygon(QPolygonF([QPointF(0, w), QPointF(0, 0), QPointF(w, 0)]))
pr.end()
_tile_cache[key] = pm
return _tile_cache[key]
# ---- the hand-built balloon wordmark ----------------------------------------
def draw_balloon_title(p: QPainter, text, center_x, baseline_y, font,
fill=C.PINK, bevel=C.MARIGOLD, phase=0.0, motion=1.0,
bounce=6.0):
"""Render `text` glyph-by-glyph as Cruikshank balloon lettering: a hard
offset shadow, a thick ink double-outline, a candy body fill, and a nudged
inner bevel. Each glyph rides a bouncing baseline."""
fm = QFontMetricsF(font)
total = fm.horizontalAdvance(text)
x = center_x - total / 2.0
for i, ch in enumerate(text):
adv = fm.horizontalAdvance(ch)
if ch != " ":
yb = baseline_y + bounce * math.sin(phase * 1.7 + i * 0.6) * motion
gp = QPainterPath()
gp.addText(x, yb, font, ch)
# (a) hard offset drop shadow -- no blur
p.save(); p.translate(4, 5); p.fillPath(gp, C.INK); p.restore()
# (b) thick ink double-outline
pen = QPen(C.INK, 8.0)
pen.setJoinStyle(Qt.RoundJoin); pen.setCapStyle(Qt.RoundCap)
p.strokePath(gp, pen)
# (c) candy body
p.fillPath(gp, fill)
# (d) inner bevel: a scaled copy nudged up-left
br = gp.boundingRect(); gc = br.center()
p.save()
p.translate(gc); p.scale(0.9, 0.9); p.translate(-gc.x(), -gc.y())
p.translate(-1.5, -2.5)
p.fillPath(gp, bevel)
p.restore()
x += adv
# =============================================================================
# MODEL -- plain data + JSON persistence
# =============================================================================
@dataclass
class Task:
text: str
done: bool = False
created: float = 0.0
class TodoStore:
def __init__(self):
self.tasks: list[Task] = []
# AppConfigLocation already nests <org>/<app>; only the fallback needs a name
base = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)
if base:
self.dir = base
else:
self.dir = os.path.join(os.path.expanduser("~"), ".config", "Quackadero")
self.path = os.path.join(self.dir, "tickets.json")
def load(self):
try:
with open(self.path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
self.tasks = [Task(text=str(t.get("text", "")),
done=bool(t.get("done", False)),
created=float(t.get("created", 0.0)))
for t in raw if str(t.get("text", "")).strip()]
except (OSError, ValueError, TypeError):
self.tasks = []
return self.tasks
def save(self):
try:
os.makedirs(self.dir, exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump([asdict(t) for t in self.tasks], fh, indent=2)
os.replace(tmp, self.path)
except OSError:
pass # a to-do app should never crash because the disk is grumpy
# =============================================================================
# WIDGETS
# =============================================================================
DISPLAY_FAMILY = "DejaVu Serif" # resolved at runtime in main()
BODY_FAMILY = "DejaVu Sans"
def display_font(px, bold=True):
f = QFont(DISPLAY_FAMILY)
f.setPixelSize(int(px))
f.setBold(bold)
f.setStyleStrategy(QFont.PreferAntialias)
return f
def body_font(px, weight=QFont.Medium):
f = QFont(BODY_FAMILY)
f.setPixelSize(int(px))
f.setWeight(weight)
f.setStyleStrategy(QFont.PreferAntialias)
return f
# -----------------------------------------------------------------------------
class GrinCheckbox(BoilMixin, QWidget):
"""A tiny anthropomorphic creature that stands in for a checkbox. It watches
the task text; ticking it squashes its eyes shut into a grin and it goes
mint-green and happy. The full 34x34 is the click target."""
toggled = Signal()
def __init__(self, done=False, parent=None):
super().__init__(parent)
self.setFixedSize(34, 34)
self.setCursor(Qt.PointingHandCursor)
self._check = 1.0 if done else 0.0
clock.tick.connect(self.update)
self._anim = QPropertyAnimation(self, b"check", self)
self._anim.setDuration(240)
self._anim.setEasingCurve(QEasingCurve.OutBack)
def _get_check(self):
return self._check
def _set_check(self, v):
self._check = v
self.update()
check = Property(float, _get_check, _set_check)
def set_done(self, done, animate=True):
target = 1.0 if done else 0.0
if animate and clock.motion():
self._anim.stop()
self._anim.setStartValue(self._check)
self._anim.setEndValue(target)
self._anim.start()
else:
self._set_check(target)
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton:
self.toggled.emit()
else:
super().mousePressEvent(ev)
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
c = max(0.0, min(1.0, self._check))
cx, cy = self.width() / 2, self.height() / 2
pop = 1.0 + 0.16 * math.sin(c * math.pi)
p.translate(cx, cy); p.scale(pop, pop); p.translate(-cx, -cy)
body = rounded_path(QRectF(4, 4, 26, 26), 7)
body = self.boiled("box", body, 1.4)
p.fillPath(body, lerp_color(C.CREAM, C.MINT, 0.5 * c))
p.strokePath(body, QPen(C.INK, 3))
look = eye_look(self, QPointF(cx, cy), reach=1.0)
ex, ey = cx, cy - 2
if c < 0.5:
draw_googly(p, ex - 6, ey, 5.0, look, rim=2.2)
draw_googly(p, ex + 6, ey, 5.0, look, rim=2.2)
p.setPen(QPen(C.INK, 2.2))
p.drawLine(QPointF(cx - 5, cy + 8), QPointF(cx + 5, cy + 8))
else:
# happy: closed up-arc eyes + wide grin
p.setPen(QPen(C.INK, 2.4))
for sx in (-6, 6):
arc = QPainterPath()
arc.moveTo(cx + sx - 4, ey + 1)
arc.quadTo(cx + sx, ey - 4, cx + sx + 4, ey + 1)
p.strokePath(arc, QPen(C.INK, 2.4))
grin = QPainterPath()
grin.moveTo(cx - 7, cy + 5)
grin.quadTo(cx, cy + 13, cx + 7, cy + 5)
p.strokePath(grin, QPen(C.INK, 2.4))
p.end()
# -----------------------------------------------------------------------------
class StubButton(BoilMixin, QWidget):
"""A red circular ticket-stub button with an ink x -- 'tear the ticket'."""
clicked = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(30, 30)
self.setCursor(Qt.PointingHandCursor)
self._hover = False
self._squash = 1.0
clock.tick.connect(self.update)
self._a = QPropertyAnimation(self, b"squash", self)
self._a.setDuration(160)
self._a.setEasingCurve(QEasingCurve.OutBack)
def _get_squash(self):
return self._squash
def _set_squash(self, v):
self._squash = v
self.update()
squash = Property(float, _get_squash, _set_squash)
def enterEvent(self, _):
self._hover = True; self.update()
def leaveEvent(self, _):
self._hover = False; self.update()
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton:
self._a.stop(); self._a.setStartValue(0.82); self._a.setEndValue(1.0); self._a.start()
self.clicked.emit()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
cx, cy = self.width() / 2, self.height() / 2
s = self._squash
p.translate(cx, cy); p.scale(s, 2 - s); p.translate(-cx, -cy)
circle = QPainterPath(); circle.addEllipse(QRectF(4, 4, 22, 22))
circle = self.boiled("stub", circle, 1.2)
p.fillPath(circle, C.MAGENTA if self._hover else C.RED)
p.strokePath(circle, QPen(C.INK, 3))
p.setPen(QPen(C.INK, 3, Qt.SolidLine, Qt.RoundCap))
p.drawLine(QPointF(cx - 5, cy - 5), QPointF(cx + 5, cy + 5))
p.drawLine(QPointF(cx - 5, cy + 5), QPointF(cx + 5, cy - 5))
p.end()
# -----------------------------------------------------------------------------
class AddButton(BoilMixin, QWidget):
"""A fat round pink '+' that squashes on press and recoils with elastic."""
clicked = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(54, 54)
self.setCursor(Qt.PointingHandCursor)
self._hover = False
self._squash = 1.0
clock.tick.connect(self.update)
self._a = QPropertyAnimation(self, b"squash", self)
def _get_squash(self):
return self._squash
def _set_squash(self, v):
self._squash = v
self.update()
squash = Property(float, _get_squash, _set_squash)
def enterEvent(self, _):
self._hover = True; self.update()
def leaveEvent(self, _):
self._hover = False; self.update()
def pop(self):
self._a.stop()
self._a.setDuration(420)
self._a.setEasingCurve(QEasingCurve.OutElastic)
self._a.setStartValue(0.7); self._a.setEndValue(1.0); self._a.start()
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton:
self._set_squash(0.85)
self.clicked.emit()
def mouseReleaseEvent(self, ev):
self.pop()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
cx, cy = self.width() / 2, self.height() / 2
breathe = 1.0 + 0.03 * math.sin(clock.phase * 2.2) * clock.motion()
s = self._squash * breathe
p.translate(cx, cy); p.scale(s, 2 * breathe - s); p.translate(-cx, -cy)
body = QPainterPath(); body.addEllipse(QRectF(5, 5, 44, 44))
body = self.boiled("add", body, 1.6)
p.fillPath(body, C.CYAN if self._hover else C.PINK)
p.strokePath(body, QPen(C.INK, 3.5))
p.setPen(QPen(C.INK, 5, Qt.SolidLine, Qt.RoundCap))
p.drawLine(QPointF(cx - 11, cy), QPointF(cx + 11, cy))
p.drawLine(QPointF(cx, cy - 11), QPointF(cx, cy + 11))
p.end()
# -----------------------------------------------------------------------------
class EyeButton(QWidget):
"""A googly-eye window control. The whole carnival can see you; these blink
and then act (close or minimise)."""
def __init__(self, iris: QColor, action, parent=None):
super().__init__(parent)
self.setFixedSize(30, 30)
self.setCursor(Qt.PointingHandCursor)
self._iris = iris
self._action = action
self._blink = 0.0
clock.tick.connect(self.update)
self._a = QPropertyAnimation(self, b"blink", self)
self._a.setDuration(120)
self._a.finished.connect(self._after_blink)
self._pending = False
def _get_blink(self):
return self._blink
def _set_blink(self, v):
self._blink = v; self.update()
blink = Property(float, _get_blink, _set_blink)
def _after_blink(self):
if self._pending:
self._pending = False
self._action()
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton:
if clock.motion():
self._pending = True
self._a.stop(); self._a.setKeyValueAt(0, 0.0)
self._a.setKeyValueAt(0.5, 1.0); self._a.setKeyValueAt(1.0, 0.0)
self._a.start()
else:
self._action()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
cx, cy = self.width() / 2, self.height() / 2
p.setPen(QPen(C.INK, 3)); p.setBrush(C.CREAM)
p.drawEllipse(QPointF(cx, cy), 13, 13)
look = eye_look(self, QPointF(cx, cy))
h = 9 * (1 - 0.9 * self._blink)
p.setPen(Qt.NoPen); p.setBrush(self._iris)
p.drawEllipse(QPointF(cx + look.x() * 3, cy + look.y() * 3), 6, max(0.5, h * 6 / 9))
if self._blink < 0.5:
p.setBrush(C.INK)
p.drawEllipse(QPointF(cx + look.x() * 4, cy + look.y() * 4), 2.4, 2.4 * h / 9)
p.end()
# -----------------------------------------------------------------------------
class QuasiMascot(BoilMixin, QWidget):
"""Quasi the duck: pear body, thick round glasses, buck teeth, a cherry cape,
paddle feet. Bobs and blinks at idle, pupils track the cursor, and reacts to
events (glee on add/complete, sheepish duck-down on delete)."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(92, 108)
self._bob = 0.0
self._sx = 1.0
self._sy = 1.0
self._blink = 0.0
self._dip = 0.0
self._grin = 0.0
clock.tick.connect(self.update)
self._react = QPropertyAnimation(self, b"sy", self)
self._reactx = QPropertyAnimation(self, b"sx", self)
self._dipanim = QPropertyAnimation(self, b"dip", self)
self._blinkanim = QPropertyAnimation(self, b"blink", self)
self._blinkanim.setDuration(130)
self._grinanim = QPropertyAnimation(self, b"grin", self)
self._schedule_blink()
# -- animatable properties --
def _g(name):
def getter(self): return getattr(self, "_" + name)
def setter(self, v): setattr(self, "_" + name, v); self.update()
return Property(float, getter, setter)
sx = _g("sx"); sy = _g("sy"); blink = _g("blink"); dip = _g("dip"); grin = _g("grin")
del _g
def _schedule_blink(self):
QTimer.singleShot(random.randint(2000, 5200), self._do_blink)
def _do_blink(self):
if clock.motion():
self._blinkanim.stop()
self._blinkanim.setKeyValueAt(0, 0.0)
self._blinkanim.setKeyValueAt(0.5, 1.0)
self._blinkanim.setKeyValueAt(1.0, 0.0)
self._blinkanim.start()
self._schedule_blink()
def celebrate(self):
if not clock.motion():
return
self._react.stop()
self._react.setDuration(680); self._react.setEasingCurve(QEasingCurve.OutElastic)
self._react.setKeyValueAt(0, 1.0); self._react.setKeyValueAt(0.3, 1.32)
self._react.setKeyValueAt(0.6, 0.9); self._react.setKeyValueAt(1.0, 1.0)
self._react.start()
# squash the width inversely so the glee bounce conserves volume
self._reactx.stop()
self._reactx.setDuration(680); self._reactx.setEasingCurve(QEasingCurve.OutElastic)
self._reactx.setKeyValueAt(0, 1.0); self._reactx.setKeyValueAt(0.3, 0.82)
self._reactx.setKeyValueAt(0.6, 1.08); self._reactx.setKeyValueAt(1.0, 1.0)
self._reactx.start()
self._grinanim.stop(); self._grinanim.setDuration(1400)
self._grinanim.setKeyValueAt(0, 1.0); self._grinanim.setKeyValueAt(0.7, 1.0)
self._grinanim.setKeyValueAt(1.0, 0.0); self._grinanim.start()
def lament(self):
if not clock.motion():
return
self._dipanim.stop()
self._dipanim.setDuration(620); self._dipanim.setEasingCurve(QEasingCurve.OutBack)
self._dipanim.setKeyValueAt(0, 0.0); self._dipanim.setKeyValueAt(0.35, 1.0)
self._dipanim.setKeyValueAt(1.0, 0.0); self._dipanim.start()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
W, H = self.width(), self.height()
cx = W / 2
bob = 3.0 * math.sin(clock.phase * 1.8) * clock.motion()
dip = self._dip * 10
p.translate(0, bob + dip)
# squash about the body centre for reactions
bcx, bcy = cx, H * 0.62
p.translate(bcx, bcy); p.scale(self._sx, self._sy); p.translate(-bcx, -bcy)
sway = math.sin(clock.phase * 1.5) * 4 * clock.motion()
# (1) CAPE -- cherry scalloped path behind the body
p.save()
p.translate(cx, H * 0.36); p.rotate(sway); p.translate(-cx, -H * 0.36)
cape = QPainterPath()
cape.moveTo(cx - 10, H * 0.34)
cape.lineTo(cx - 30, H * 0.80)
for k in range(4):
x0 = cx - 30 + k * 20
cape.quadTo(x0 + 5, H * 0.88, x0 + 20, H * 0.80)
cape.lineTo(cx + 10, H * 0.34)
cape.closeSubpath()
p.fillPath(cape, C.RED); p.strokePath(cape, QPen(C.INK, 3))
p.restore()
# (2) FEET
p.setPen(QPen(C.INK, 3)); p.setBrush(C.TANGERINE)
for fx in (cx - 13, cx + 13):
p.drawRoundedRect(QRectF(fx - 9, H * 0.84, 20, 11), 5, 5)
# (3) BODY -- pear (head circle united with body egg)
body = QPainterPath(); body.addEllipse(QRectF(cx - 30, H * 0.45, 60, 56))
head = QPainterPath(); head.addEllipse(QRectF(cx - 23, H * 0.18, 46, 46))
body = body.united(head)
body = self.boiled("duck", body, 1.4)
p.fillPath(body, C.MARIGOLD); p.strokePath(body, QPen(C.INK, 3))
# (4) BEAK
beak = QPolygonF([QPointF(cx - 13, H * 0.40), QPointF(cx + 13, H * 0.40),
QPointF(cx, H * 0.50)])
p.setPen(QPen(C.INK, 3)); p.setBrush(C.TANGERINE); p.drawPolygon(beak)
p.drawLine(QPointF(cx - 9, H * 0.43), QPointF(cx + 9, H * 0.43))
# (5) BUCK TEETH
p.setBrush(C.CREAM); p.setPen(QPen(C.INK, 2))
for tx in (cx - 5, cx + 1):
p.drawRoundedRect(QRectF(tx, H * 0.50, 5, 7), 1.5, 1.5)
# (6) MOUTH -- a grin appears during glee
if self._grin > 0.05:
p.setPen(QPen(C.INK, 2.4))
grin = QPainterPath(); grin.moveTo(cx - 9, H * 0.52)
grin.quadTo(cx, H * 0.52 + 8 * self._grin, cx + 9, H * 0.52)
p.strokePath(grin, QPen(C.INK, 2.4))
# (7) GLASSES + (8) PUPILS (cursor-tracking, blink-aware)
look = eye_look(self, QPointF(cx, H * 0.30), reach=1.0)
p.setPen(QPen(C.INK, 3)); p.setBrush(C.CREAM)
lens = 12
eye_y = H * 0.30
for ex in (cx - 12, cx + 12):
p.drawEllipse(QPointF(ex, eye_y), lens, lens)
p.drawLine(QPointF(cx - 1, eye_y), QPointF(cx + 1, eye_y))
for ex in (cx - 12, cx + 12):
h = lens * (1 - 0.9 * self._dip) # look down when sheepish
blink = self._blink
ph = lens * (1 - 0.92 * blink)
ly = look.y() + (0.7 if self._dip > 0.1 else 0)
p.setPen(Qt.NoPen); p.setBrush(C.INK)
p.drawEllipse(QPointF(ex + look.x() * 5, eye_y + ly * 5),
4.2, max(0.5, 4.2 * ph / lens))
if blink < 0.5:
p.setBrush(with_alpha(C.CREAM, 220))
p.drawEllipse(QPointF(ex + look.x() * 5 - 1.4, eye_y + ly * 5 - 1.6), 1.4, 1.4)
p.end()
# -----------------------------------------------------------------------------
class TicketRow(BoilMixin, QWidget):
"""One admission ticket. Breathes on a staggered sine, its ink outline boils,
a grinning checkbox toggles 'done' (firing the RIDDEN! stamp + bloom), and the
red stub tears it up with a wilt-and-drip melt."""
request_delete = Signal(object)
request_edit = Signal(object, str)
request_toggle = Signal(object)
H = 74
def __init__(self, task: Task, index, parent=None):
super().__init__(parent)
self.task = task
self.index = index
self.setMinimumHeight(self.H)
self.setMaximumHeight(self.H)
self.setMouseTracking(True)
self._hover = False
self._celebrate = 1.0 if task.done else 0.0
self._collapse = 0.0
self._deleting = False
self.check = GrinCheckbox(task.done, self)
self.check.toggled.connect(lambda: self.request_toggle.emit(self))
self.stub = StubButton(self)
self.stub.clicked.connect(self._begin_delete)
self.editor = QLineEdit(self)
self.editor.setFont(body_font(17))
self.editor.setFrame(False)
self.editor.setStyleSheet(
"QLineEdit{background:transparent;border:none;color:#231F20;"
"selection-background-color:#F25C9E;selection-color:#FBF3DC;padding:0;}")
self.editor.hide()
self.editor.editingFinished.connect(self._commit_edit)
self._editing = False
clock.tick.connect(self.update)
self._cel_anim = QPropertyAnimation(self, b"celebrate", self)
self._cel_anim.setDuration(720)
self._col_anim = QPropertyAnimation(self, b"collapse", self)
self._col_anim.setDuration(560)
self._col_anim.setEasingCurve(QEasingCurve.InOutSine)
self._col_anim.finished.connect(lambda: self.request_delete.emit(self))
# -- animatable properties --
def _get_cel(self): return self._celebrate
def _set_cel(self, v): self._celebrate = v; self.update()
celebrate = Property(float, _get_cel, _set_cel)
def _get_col(self): return self._collapse
def _set_col(self, v):
self._collapse = v
h = int(self.H * (1.0 - v))
self.setMinimumHeight(h); self.setMaximumHeight(h)
self.update()
collapse = Property(float, _get_col, _set_col)
# -- geometry --
def resizeEvent(self, _):
cy = self.H // 2
self.check.move(50, cy - self.check.height() // 2)
self.stub.move(self.width() - 30 - 24, cy - self.stub.height() // 2)
self._place_editor()
def _text_rect(self):
left = self.check.x() + self.check.width() + 14
right = self.stub.x() - 12
return QRectF(left, 8, max(10, right - left), self.H - 16)
def _place_editor(self):
r = self._text_rect()
self.editor.setGeometry(int(r.left()), int(self.H / 2 - 14),
int(r.width()), 28)
# -- interactions --
def set_done_visual(self, done):
self.check.set_done(done)
if done:
self._cel_anim.stop()
if clock.motion():
self._cel_anim.setStartValue(0.0); self._cel_anim.setEndValue(1.0)
self._cel_anim.start()
else:
self._set_cel(1.0)
else:
self._set_cel(0.0)
def _begin_delete(self):
if self._deleting:
return
self._deleting = True
self.stub.setEnabled(False)
self.check.setEnabled(False)
if clock.motion():
self._col_anim.stop()
self._col_anim.setStartValue(0.0); self._col_anim.setEndValue(1.0)
self._col_anim.start()
else:
self.request_delete.emit(self)
def mouseDoubleClickEvent(self, ev):
if self._text_rect().contains(ev.position()) and not self.task.done:
self._start_edit()
def _start_edit(self):
self._editing = True
self._place_editor()
self.editor.setText(self.task.text)
self.editor.show(); self.editor.setFocus(); self.editor.selectAll()
self.update()
def _commit_edit(self):
if not self._editing:
return
self._editing = False
new = self.editor.text().strip()
self.editor.hide()
if new and new != self.task.text:
self.request_edit.emit(self, new)
self.update()
def keyPressEvent(self, ev):
if self._editing and ev.key() == Qt.Key_Escape:
self._editing = False; self.editor.hide(); self.update(); return
if ev.key() == Qt.Key_Space:
self.request_toggle.emit(self); return
if ev.key() in (Qt.Key_Delete, Qt.Key_Backspace):
self._begin_delete(); return
super().keyPressEvent(ev)
def enterEvent(self, _):
self._hover = True; self.update()
def leaveEvent(self, _):
self._hover = False; self.update()
# -- painting --
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setRenderHint(QPainter.TextAntialiasing)
col = self._collapse
if col > 0:
self._paint_collapsing(p, col)
return
w = self.width()
cyc = self.H / 2
breathe = 1.0 + 0.016 * math.sin(clock.phase * 1.3 + self.index * 0.5) * clock.motion()
done = self.task.done
# ---- card (boils + breathes; children stay put) ----
p.save()
p.translate(w / 2, cyc); p.scale(breathe, breathe); p.translate(-w / 2, -cyc)
rect = QRectF(22, 7, w - 44, self.H - 14)
card = ticket_path(rect, 14, 9)
card = self.boiled("card", card, 2.0)
# hard offset shadow
p.save(); p.translate(3, 4); p.fillPath(card, with_alpha(C.INK, 90)); p.restore()
fill = C.CREAM
if self._hover and not done:
fill = lerp_color(C.CREAM, C.PINK, 0.16)
p.fillPath(card, fill)
if done:
glow = QRadialGradient(self.check.x() + 17, cyc, 60)
glow.setColorAt(0, with_alpha(C.CYAN, 70)); glow.setColorAt(1, with_alpha(C.CYAN, 0))
p.fillPath(card, glow)
p.strokePath(card, QPen(C.INK, 3.0 if not (self._hover or done) else 3.6))
# order badge (the ticket's number), tucked by the left stub
bx, by = rect.left() + 14, cyc
p.setPen(QPen(C.INK, 2.4)); p.setBrush(C.MARIGOLD)
p.drawEllipse(QPointF(bx, by), 10, 10)
p.setPen(C.INK); p.setFont(display_font(12))
p.drawText(QRectF(bx - 11, by - 11, 22, 22), Qt.AlignCenter, str(self.index + 1))
p.restore()
# ---- task text (NOT boiled / not jittered -- legibility) ----
if not self._editing:
r = self._text_rect()
p.setFont(body_font(17))
p.setPen(C.TEAL if done else C.INK)
metrics = QFontMetricsF(p.font())
txt = metrics.elidedText(self.task.text, Qt.ElideRight, int(r.width()))
p.drawText(r, Qt.AlignVCenter | Qt.AlignLeft, txt)
if done:
tw = min(metrics.horizontalAdvance(self.task.text), r.width())
ty = r.center().y() + 1
strike = QPainterPath(); strike.moveTo(r.left(), ty)
n = 22
wob = clock.motion() # Calm Mode flattens the strike to a steady line
for i in range(1, n + 1):
strike.lineTo(r.left() + tw * i / n,
ty + 2.4 * math.sin(i * 0.9 + clock.phase) * wob)
p.strokePath(strike, QPen(C.TANGERINE, 2.4))
# ---- celebration: stamp + bloom + sparkles ----
if 0 < self._celebrate < 1.0 and done:
self._paint_celebration(p)
p.end()
def _paint_celebration(self, p):
cel = self._celebrate
ccx, ccy = self.check.x() + 17, self.H / 2
# bloom starburst behind the checkbox
bloom = QEasingCurve(QEasingCurve.OutBack).valueForProgress(min(1.0, cel * 1.6))
a = int(220 * (1 - cel))
if a > 4:
p.save(); p.translate(ccx, ccy)
p.rotate(cel * 40)
poly = starburst_polygon(12, 6, 30 * bloom)
p.setPen(Qt.NoPen); p.setBrush(with_alpha(C.TANGERINE, a))
p.drawPolygon(poly)
p.setBrush(with_alpha(C.MARIGOLD, a))
p.drawPolygon(starburst_polygon(12, 4, 20 * bloom, rot=0.26))
p.restore()
# sparkle sprites flying outward
for i in range(8):
ang = i * (2 * math.pi / 8) + 0.3
dist = 14 + 40 * bloom
sx = ccx + math.cos(ang) * dist
sy = ccy + math.sin(ang) * dist - 6 * cel
sa = int(255 * (1 - cel))
if sa <= 4:
continue
if i % 2 == 0:
star = starburst_polygon(5, 1.4, 5.0 * (1 - 0.4 * cel))
p.save(); p.translate(sx, sy); p.rotate(cel * 220 + i * 30)
p.setPen(Qt.NoPen); p.setBrush(with_alpha(C.MARIGOLD, sa))
p.drawPolygon(star); p.restore()
else:
p.setPen(Qt.NoPen); p.setBrush(with_alpha(C.CYAN, sa))
p.drawEllipse(QPointF(sx, sy), 3.0 * (1 - 0.4 * cel), 3.0 * (1 - 0.4 * cel))
# RIDDEN! rubber stamp THWACKS down
stamp_t = min(1.0, cel / 0.5)
if stamp_t > 0:
sc = 1.6 - 0.6 * QEasingCurve(QEasingCurve.OutBack).valueForProgress(stamp_t)
shake = (1 - stamp_t) * 3 * math.sin(cel * 50)
scx, scy = self.width() * 0.62, self.H / 2
p.save(); p.translate(scx + shake, scy); p.rotate(-18); p.scale(sc, sc)
p.setPen(QPen(with_alpha(C.RED, 235), 2.4))
p.setBrush(Qt.NoBrush)
p.drawRoundedRect(QRectF(-46, -15, 92, 30), 6, 6)
p.setFont(display_font(17))
p.drawText(QRectF(-46, -15, 92, 30), Qt.AlignCenter, "RIDDEN! ★")
p.restore()
def _paint_collapsing(self, p, col):
# wilt-and-drip: squash flat, stretch wide, ooze teardrops, fade out
p.setOpacity(max(0.0, 1.0 - col))
w = self.width()
h = max(1, int(self.H * (1 - col)))
p.save()
p.translate(w / 2, h / 2)
p.scale(1.0 + 0.14 * col, 1.0)
p.translate(-w / 2, -h / 2)
rect = QRectF(22, 4, w - 44, max(2, h - 8))
card = ticket_path(rect, 12, 8)
tint = lerp_color(C.CREAM, C.GRAPE, min(1.0, col * 1.4))
p.fillPath(card, tint); p.strokePath(card, QPen(C.INK, 3))
p.restore()
# teardrops accelerate downward
p.setOpacity(max(0.0, 1.0 - col * 0.8))
for i in range(3):
t = min(1.0, col * 1.3)
dx = w * (0.3 + 0.2 * i)
dy = h + (t * t) * 60
drop = QPainterPath()
drop.addEllipse(QPointF(dx, dy), 6 - i, 7 - i)
drop.moveTo(dx, dy - 10); drop.quadTo(dx + 5, dy - 4, dx, dy)
p.fillPath(drop, with_alpha(C.GRAPE, int(200 * (1 - col))))
p.end()
# -----------------------------------------------------------------------------
class StripeProgress(QWidget):
"""A barber-pole candy progress bar. The done-chunk's diagonal stripes crawl
via the shared clock; the cap bulb brightens at 100%."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(20)
self.setMinimumWidth(80)
self._ratio = 0.0
clock.tick.connect(self.update)
def set_ratio(self, r):
self._ratio = max(0.0, min(1.0, r)); self.update()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
track = QRectF(2, 2, w - 4, h - 4)
p.setPen(QPen(C.INK, 2.5)); p.setBrush(C.GRAPE)
p.drawRoundedRect(track, h / 2, h / 2)
if self._ratio > 0.001:
cw = (w - 4) * self._ratio
chunk = QRectF(2, 2, max(h, cw), h - 4)
p.save()
clip = QPainterPath(); clip.addRoundedRect(chunk, h / 2, h / 2)
p.setClipPath(clip)
brush = QBrush(stripe_tile(C.TANGERINE, C.MARIGOLD, 11))
off = (clock.phase * 26 * clock.motion()) % 26
brush.setTransform(brush.transform().translate(-off, 0))
p.fillRect(chunk, brush)
p.restore()
p.setPen(QPen(C.INK, 2.5)); p.setBrush(Qt.NoBrush)
p.drawRoundedRect(chunk, h / 2, h / 2)
# cap bulb
bulb = QPointF(chunk.right() - h / 2, h / 2 + 1)
full = self._ratio >= 0.999
p.setPen(QPen(C.INK, 2)); p.setBrush(C.CREAM if not full else C.MARIGOLD)
p.drawEllipse(bulb, h / 3, h / 3)
p.end()
# -----------------------------------------------------------------------------
class FilterChip(QPushButton):
def __init__(self, text, parent=None):
super().__init__(text, parent)
self.setCheckable(True)
self.setCursor(Qt.PointingHandCursor)
self.setFont(body_font(12, QFont.Bold))
self.setStyleSheet("""
QPushButton{background:#FBF3DC;color:#231F20;border:2px solid #231F20;
border-radius:11px;padding:3px 9px;}
QPushButton:hover{background:#1FC8E0;}
QPushButton:checked{background:#F25C9E;color:#FBF3DC;}
""")
# =============================================================================
# BACKDROP -- the whole marquee sign is painted here, behind the widgets.
# =============================================================================
class Backdrop(QWidget):
AWNING_H = 132
FRAME = 17
def __init__(self, parent=None):
super().__init__(parent)
self._sky = 0.0
clock.tick.connect(self.update)
self._skyanim = QPropertyAnimation(self, b"sky", self)
self._skyanim.setDuration(30000)
self._skyanim.setStartValue(0.0); self._skyanim.setEndValue(1.0)
self._skyanim.setEasingCurve(QEasingCurve.InOutSine)
self._skyanim.setLoopCount(-1)
self._skyanim.start()
def _get_sky(self): return self._sky
def _set_sky(self, v): self._sky = v; self.update()
sky = Property(float, _get_sky, _set_sky)
def set_calm(self, calm):
if calm:
self._skyanim.pause()
else:
self._skyanim.resume()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
# (1) breathing sunset sky
drift = 0.08 * math.sin(self._sky * 2 * math.pi)
g = QLinearGradient(0, 0, 0, h)
g.setColorAt(0.0, C.SKY_TOP)
g.setColorAt(max(0.30, min(0.75, 0.55 + drift)), C.SKY_MID)
g.setColorAt(1.0, C.GRAPE)
p.fillRect(self.rect(), g)
# funhouse cyan bloom near the top centre
bloom = QRadialGradient(w / 2, self.AWNING_H, w * 0.7)
bloom.setColorAt(0, with_alpha(C.CYAN, 42)); bloom.setColorAt(1, with_alpha(C.CYAN, 0))
p.fillRect(self.rect(), bloom)
self._paint_awning(p, w)
self._paint_marquee_frame(p, w, h)
def _paint_awning(self, p, w):
AH = self.AWNING_H
F = self.FRAME
# rotating sunburst behind the title
p.save()
p.translate(w / 2, AH * 0.46)
rot = clock.phase * 9 * clock.motion()
p.rotate(rot)
rays = 24
for i in range(rays):
p.save(); p.rotate(i * 360 / rays)
col = C.MARIGOLD if i % 2 == 0 else C.TANGERINE
wedge = QPolygonF([QPointF(0, 0), QPointF(-26, -210), QPointF(26, -210)])
p.setPen(Qt.NoPen); p.setBrush(with_alpha(col, 150))
p.drawPolygon(wedge); p.restore()
p.restore()
# the scalloped candy awning band
band = QRectF(F, F, w - 2 * F, AH - F)
path = QPainterPath()
path.moveTo(band.left(), band.top() + 18)
path.quadTo(band.left(), band.top(), band.left() + 18, band.top())
path.lineTo(band.right() - 18, band.top())
path.quadTo(band.right(), band.top(), band.right(), band.top() + 18)
path.lineTo(band.right(), band.bottom() - 14)
# scalloped, slightly dripping bottom edge
scallops = 9
sw = band.width() / scallops
for k in range(scallops):
x0 = band.right() - k * sw
sag = 6 * math.sin(clock.phase * 1.4 + k) * clock.motion()
path.quadTo(x0 - sw / 2, band.bottom() + 12 + sag, x0 - sw, band.bottom() - 14)
path.closeSubpath()
p.save(); p.setClipPath(path)
# barber-pole diagonal candy stripes
brush = QBrush(stripe_tile(C.PINK, C.CREAM, 15))
off = (clock.phase * 22 * clock.motion()) % 30
brush.setTransform(brush.transform().translate(off, 0))
p.fillRect(band.adjusted(-4, -4, 4, 24), brush)
p.restore()
p.strokePath(path, QPen(C.INK, 3))
# the wordmark + subtitle ribbon, centred in the band LEFT of the two
# googly window-buttons (which live at the awning's top-right corner)
left_edge = F + 16
btn_left = w - F - 74 - 16 # leftmost edge of the minimise eye-button, minus a gap
cxt = (left_edge + btn_left) / 2
avail = btn_left - left_edge
title_font = display_font(self._fit_title(avail), bold=True)
draw_balloon_title(p, "THE QUACKADERO", cxt, AH * 0.46, title_font,
phase=clock.phase, motion=clock.motion())
sub_font = display_font(15, bold=True)
ribbon = QRectF(cxt - 118, AH * 0.62, 236, 26)
rp = rounded_path(ribbon, 12)
p.fillPath(rp, C.GRAPE); p.strokePath(rp, QPen(C.INK, 2.5))
p.setPen(C.CREAM); p.setFont(sub_font)
p.drawText(ribbon, Qt.AlignCenter, "✦ ADMIT ALL TASKS ✦")
def _fit_title(self, avail):
size = 40
f = display_font(size)
fm = QFontMetricsF(f)
avail -= 16 # leave room for the 8px outline + hard shadow
adv = fm.horizontalAdvance("THE QUACKADERO")
if adv > avail:
size = max(20, size * avail / adv)
return size
def _paint_marquee_frame(self, p, w, h):
F = self.FRAME
outer = QRectF(F / 2, F / 2, w - F, h - F)
# the grape frame as a thick stroked rounded rect
p.setPen(QPen(C.GRAPE, F)); p.setBrush(Qt.NoBrush)
p.drawRoundedRect(outer, 24, 24)
# ink keylines inside and out
p.setPen(QPen(C.INK, 2.5))
p.drawRoundedRect(QRectF(2, 2, w - 4, h - 4), 27, 27)
p.drawRoundedRect(QRectF(F, F, w - 2 * F, h - 2 * F), 18, 18)
# chasing bulbs along the frame centreline
self._paint_bulbs(p, w, h, F)
def _paint_bulbs(self, p, w, h, F):
cl = QRectF(F / 2, F / 2, w - F, h - F)
r = 22 # corner radius of the centreline
# build a list of bulb anchor points around the rounded-rect centreline
pts = []
step = 26
# top & bottom edges
x = cl.left() + r
while x < cl.right() - r:
pts.append((x, cl.top())); x += step
y = cl.top() + r
while y < cl.bottom() - r:
pts.append((cl.right(), y)); y += step
x = cl.right() - r
while x > cl.left() + r:
pts.append((x, cl.bottom())); x -= step
y = cl.bottom() - r
while y > cl.top() + r:
pts.append((cl.left(), y)); y -= step
n = len(pts)
if n == 0:
return
lead = (clock.phase * 0.5 * n * clock.motion()) % n
for i, (bx, by) in enumerate(pts):
d = min((i - lead) % n, (lead - i) % n)
glow = max(0.0, 1.0 - d / 5.0)
if glow > 0.05:
halo = QRadialGradient(bx, by, 9)
halo.setColorAt(0, with_alpha(C.MARIGOLD, int(220 * glow)))
halo.setColorAt(1, with_alpha(C.MARIGOLD, 0))
p.setPen(Qt.NoPen); p.setBrush(halo)
p.drawEllipse(QPointF(bx, by), 9, 9)
p.setPen(QPen(C.INK, 1.5))
p.setBrush(lerp_color(C.CREAM, C.MARIGOLD, glow))
p.drawEllipse(QPointF(bx, by), 4, 4)
# =============================================================================
# EMPTY STATE -- the closed-for-now booth
# =============================================================================
class EmptyState(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(320)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
clock.tick.connect(self.update)
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
cx = w / 2
# smiling sun
sun = QPointF(cx, h * 0.30)
p.save(); p.translate(sun)
p.rotate(clock.phase * 6 * clock.motion())
for i in range(12):
p.save(); p.rotate(i * 30)
p.setPen(Qt.NoPen); p.setBrush(C.TANGERINE)
p.drawPolygon(QPolygonF([QPointF(-7, -38), QPointF(7, -38), QPointF(0, -54)]))
p.restore()
p.restore()
p.setPen(QPen(C.INK, 3)); p.setBrush(C.MARIGOLD)
p.drawEllipse(sun, 36, 36)
p.setPen(QPen(C.INK, 3))
for sx in (-12, 12):
arc = QPainterPath(); arc.moveTo(sun.x() + sx - 6, sun.y() - 4)
arc.quadTo(sun.x() + sx, sun.y() - 12, sun.x() + sx + 6, sun.y() - 4)
p.strokePath(arc, QPen(C.INK, 3))
grin = QPainterPath(); grin.moveTo(sun.x() - 14, sun.y() + 8)
grin.quadTo(sun.x(), sun.y() + 22, sun.x() + 14, sun.y() + 8)
p.strokePath(grin, QPen(C.INK, 3))
# speech bubble
bub = QRectF(cx - 150, h * 0.56, 300, 86)
path = rounded_path(bub, 20)
tail = QPainterPath()
tail.moveTo(cx - 14, bub.top() + 6)
tail.lineTo(cx - 30, bub.top() - 22)
tail.lineTo(cx + 6, bub.top() + 8)
path = path.united(tail)
p.save(); p.translate(3, 4); p.fillPath(path, with_alpha(C.INK, 70)); p.restore()
p.fillPath(path, C.CREAM); p.strokePath(path, QPen(C.INK, 3))
p.setPen(C.INK); p.setFont(display_font(19))
p.drawText(bub.adjusted(14, 10, -14, -10),
Qt.AlignCenter | Qt.TextWordWrap,
"Nothing on the docket—\nstep right up and add a ride!")
# scattered confetti
for i in range(10):
hx = _hash01(i, 3) * w
hy = h * 0.06 + _hash01(i, 9) * h * 0.16
col = [C.PINK, C.CYAN, C.MINT, C.MAGENTA][i % 4]
p.setPen(Qt.NoPen); p.setBrush(col)
p.drawEllipse(QPointF(hx, hy), 4, 4)
p.end()
# =============================================================================
# UNDO TOAST
# =============================================================================
class Toast(QWidget):
undo = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(300, 50)
self.setCursor(Qt.PointingHandCursor)
self.hide()
self._closing = False
self._anim = QPropertyAnimation(self, b"pos", self)
self._anim.setDuration(220)
# One shared pos-animation drives both slide-in and slide-out. Connect
# finished ONCE; only hide at the end of a slide-OUT, never a slide-in.
self._anim.finished.connect(self._on_anim_done)
self._dismiss = QTimer(self)
self._dismiss.setSingleShot(True)
self._dismiss.timeout.connect(self.slide_out)
def _on_anim_done(self):
if self._closing:
self.hide()
def show_at(self, parent_rect):
self._closing = False
x = int(parent_rect.width() / 2 - self.width() / 2)
self._home = parent_rect.height() - self.height() - 70
self.move(x, parent_rect.height())
self.show(); self.raise_()
self._anim.stop()
self._anim.setEasingCurve(QEasingCurve.OutBack)
self._anim.setStartValue(self.pos())
self._anim.setEndValue(QPoint(x, self._home))
self._anim.start()
self._dismiss.start(5000)
def slide_out(self):
self._closing = True
self._anim.stop()
self._anim.setEasingCurve(QEasingCurve.InBack)
self._anim.setStartValue(self.pos())
self._anim.setEndValue(QPoint(self.x(), self.parent().height()))
self._anim.start()
def mousePressEvent(self, ev):
self._dismiss.stop()
self.undo.emit()
self.hide()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
r = QRectF(2, 2, self.width() - 4, self.height() - 4)
path = rounded_path(r, 16)
p.save(); p.translate(2, 3); p.fillPath(path, with_alpha(C.INK, 80)); p.restore()
p.fillPath(path, C.CREAM); p.strokePath(path, QPen(C.INK, 3))
p.setPen(C.INK); p.setFont(body_font(14, QFont.Bold))
p.drawText(QRectF(16, 0, 180, self.height()), Qt.AlignVCenter | Qt.AlignLeft,
"Ticket torn up.")
chip = QRectF(self.width() - 92, 9, 80, self.height() - 18)
cp = rounded_path(chip, 13)
p.fillPath(cp, C.PINK); p.strokePath(cp, QPen(C.INK, 2.5))
p.setPen(C.CREAM); p.setFont(body_font(13, QFont.Bold))
p.drawText(chip, Qt.AlignCenter, "UNDO ↺")
p.end()
# =============================================================================
# MAIN WINDOW
# =============================================================================
FILTER_ALL, FILTER_OPEN, FILTER_DONE = 0, 1, 2
class Quackadero(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("The Quackadero")
self.resize(540, 760)
self.setMinimumSize(440, 600)
self.store = TodoStore()
self.store.load()
self.rows: list[TicketRow] = []
self.filter = FILTER_ALL
self._last_deleted = [] # list of (Task, index) from the last delete op
self._del_batch = None # collects a multi-row (TEAR RIDDEN) operation
self._del_remaining = 0
self._was_all_done = False
self.backdrop = Backdrop()
self.setCentralWidget(self.backdrop)
outer = QVBoxLayout(self.backdrop)
F = Backdrop.FRAME
outer.setContentsMargins(F + 10, Backdrop.AWNING_H + 6, F + 10, F + 10)
outer.setSpacing(10)
self._build_window_buttons()
self._build_input(outer)
self._build_list(outer)
self._build_footer(outer)
self.toast = Toast(self.backdrop)
self.toast.undo.connect(self._undo_delete)
self._install_shortcuts()
self._rebuild_rows()
self.apply_filter()
# ---- construction ----
def _build_window_buttons(self):
self.btn_close = EyeButton(C.RED, self.close, self.backdrop)
self.btn_min = EyeButton(C.CYAN, self.showMinimized, self.backdrop)
def _build_input(self, outer):
self.input_bar = QWidget()
self.input_bar.setFixedHeight(96)
lay = QHBoxLayout(self.input_bar)
lay.setContentsMargins(2, 0, 2, 0)
lay.setSpacing(8)
self.mascot = QuasiMascot()
lay.addWidget(self.mascot, 0, Qt.AlignBottom)
booth = QWidget()
booth.setObjectName("booth")
booth.setStyleSheet(
"#booth{background:#FBF3DC;border:3px solid #231F20;border-radius:18px;}")
blay = QHBoxLayout(booth)
blay.setContentsMargins(16, 6, 8, 6)
blay.setSpacing(8)
self.entry = QLineEdit()
self.entry.setPlaceholderText("What ride do you want to add?")
self.entry.setFont(body_font(16))
self.entry.setStyleSheet(
"QLineEdit{background:transparent;border:none;color:#231F20;"
"selection-background-color:#F25C9E;selection-color:#FBF3DC;}")
self.entry.returnPressed.connect(self._add_from_entry)
blay.addWidget(self.entry, 1)
self.add_btn = AddButton()
self.add_btn.clicked.connect(self._add_from_entry)
blay.addWidget(self.add_btn, 0, Qt.AlignVCenter)
lay.addWidget(booth, 1)
outer.addWidget(self.input_bar)
def _build_list(self, outer):
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# Style ONLY the scrollbar. A stylesheet on the QScrollArea itself forces
# its viewport + content to autofill an opaque background, which would
# hide the marquee sky behind the ticket list.
self.scroll.verticalScrollBar().setStyleSheet("""
QScrollBar:vertical{background:transparent;width:12px;margin:3px 2px;}
QScrollBar::handle:vertical{background:#6A3FA0;border:2px solid #231F20;
border-radius:5px;min-height:36px;}
QScrollBar::handle:vertical:hover{background:#F25C9E;}
QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical{height:0;}
QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical{background:transparent;}
""")
self.content = QWidget()
self.vbox = QVBoxLayout(self.content)
self.vbox.setContentsMargins(2, 4, 6, 12)
self.vbox.setSpacing(9)
self.vbox.addStretch(1)
self.empty = EmptyState()
self.vbox.insertWidget(0, self.empty)
self.scroll.setWidget(self.content)
# Make viewport + content transparent AFTER setWidget so the sky shows.
self.scroll.viewport().setAutoFillBackground(False)
self.content.setAutoFillBackground(False)
outer.addWidget(self.scroll, 1)
def _build_footer(self, outer):
self.footer = QWidget()
self.footer.setObjectName("footer")
self.footer.setStyleSheet(
"#footer{background:#1B8C8C;border:3px solid #231F20;border-radius:16px;}")
fl = QVBoxLayout(self.footer)
fl.setContentsMargins(14, 8, 14, 10)
fl.setSpacing(7)
top = QHBoxLayout(); top.setSpacing(8)
self.tally = QLineEdit(); self.tally.setReadOnly(True); self.tally.setFrame(False)
self.tally.setFont(display_font(14))
self.tally.setStyleSheet("QLineEdit{background:transparent;border:none;color:#FBF3DC;}")
self.tally.setFocusPolicy(Qt.NoFocus)
top.addWidget(self.tally, 1)
self.progress = StripeProgress()
top.addWidget(self.progress, 1)
fl.addLayout(top)
bottom = QHBoxLayout(); bottom.setSpacing(6)
self.chips = []
for i, name in enumerate(("ALL", "OPEN", "RIDDEN")):
chip = FilterChip(name)
chip.clicked.connect(lambda _=False, idx=i: self._set_filter(idx))
self.chips.append(chip)
bottom.addWidget(chip)
self.chips[0].setChecked(True)
bottom.addStretch(1)
self.clear_btn = FilterChip("TEAR")
self.clear_btn.setCheckable(False)
self.clear_btn.setToolTip("Tear up all ridden tickets")
self.clear_btn.clicked.connect(self._clear_done)
bottom.addWidget(self.clear_btn)
self.calm_btn = FilterChip("CALM")
self.calm_btn.setToolTip("Calm Mode: freeze all idle motion")
self.calm_btn.clicked.connect(self._toggle_calm)
bottom.addWidget(self.calm_btn)
fl.addLayout(bottom)
outer.addWidget(self.footer)
def _install_shortcuts(self):
QShortcut(QKeySequence(Qt.Key_Escape), self, self._on_escape)
QShortcut(QKeySequence("Ctrl+L"), self, lambda: self.entry.setFocus())
# ---- geometry upkeep ----
def resizeEvent(self, ev):
super().resizeEvent(ev)
w = self.backdrop.width()
self.btn_close.move(w - Backdrop.FRAME - 38, Backdrop.FRAME + 6)
self.btn_min.move(w - Backdrop.FRAME - 74, Backdrop.FRAME + 6)
# ---- row management ----
def _rebuild_rows(self):
for row in self.rows:
row.setParent(None); row.deleteLater()
self.rows.clear()
for i, task in enumerate(self.store.tasks):
row = self._make_row(task, i)
self.vbox.insertWidget(self.vbox.count() - 1, row)
self.rows.append(row)
self._renumber()
self.apply_filter()
self._update_footer()
def _make_row(self, task, index):
row = TicketRow(task, index)
row.request_toggle.connect(self._toggle_row)
row.request_delete.connect(self._finalize_delete)
row.request_edit.connect(self._edit_row)
return row
def _renumber(self):
for i, row in enumerate(self.rows):
row.index = i
def _add_from_entry(self):
text = self.entry.text().strip()
if not text:
return
self.entry.clear()
task = Task(text=text, done=False, created=clock.phase)
self.store.tasks.append(task)
self.store.save()
row = self._make_row(task, len(self.rows))
self.vbox.insertWidget(self.vbox.count() - 1, row)
self.rows.append(row)
self._renumber()
self.apply_filter()
self._boing_in(row)
self.mascot.celebrate()
self._update_footer()
QTimer.singleShot(0, lambda: self.scroll.ensureWidgetVisible(row))
def _boing_in(self, row):
if not clock.motion():
return
anim = QPropertyAnimation(row, b"maximumHeight", row)
anim.setDuration(420); anim.setEasingCurve(QEasingCurve.OutBounce)
anim.setStartValue(0); anim.setEndValue(TicketRow.H)
anim.finished.connect(lambda: row.setMaximumHeight(TicketRow.H))
row._intro = anim # keep a ref alive
anim.start()
def _toggle_row(self, row):
if row._deleting: # ignore a (keyboard) toggle on a tearing row
return
row.task.done = not row.task.done
self.store.save()
row.set_done_visual(row.task.done)
if row.task.done:
self.mascot.celebrate()
self._update_footer()
# if the current filter hides it, gently remove from view after the cheer
if not self._matches(row.task):
def hide_later(r=row):
# the row may have been torn up before this fires -- it is pulled
# from self.rows in _finalize_delete, so this stays off dead C++
if r in self.rows and not r._deleting:
r.setVisible(False)
self._refresh_empty()
QTimer.singleShot(820 if clock.motion() else 0, hide_later)
self._refresh_empty()
def _edit_row(self, row, new_text):
row.task.text = new_text
self.store.save()
row.update()
def _finalize_delete(self, row):
rec = None
if row.task in self.store.tasks:
idx = self.store.tasks.index(row.task)
rec = (row.task, idx)
self.store.tasks.remove(row.task)
self.store.save()
if row in self.rows:
self.rows.remove(row)
self.vbox.removeWidget(row)
row.setParent(None); row.deleteLater()
self._renumber()
self.mascot.lament()
self.apply_filter()
self._update_footer()
# Group a TEAR-RIDDEN batch into a single undoable op + a single toast.
if self._del_batch is not None:
if rec:
self._del_batch.append(rec)
self._del_remaining -= 1
if self._del_remaining <= 0:
self._last_deleted = self._del_batch
self._del_batch = None
if self._last_deleted:
self.toast.show_at(self.backdrop.rect())
else:
self._last_deleted = [rec] if rec else []
if rec:
self.toast.show_at(self.backdrop.rect())
def _undo_delete(self):
if not self._last_deleted:
return
batch = self._last_deleted
self._last_deleted = []
for task, idx in sorted(batch, key=lambda ti: ti[1]):
idx = max(0, min(idx, len(self.store.tasks)))
self.store.tasks.insert(idx, task)
self.store.save()
self._rebuild_rows()
def _clear_done(self):
done_rows = [r for r in self.rows if r.task.done and not r._deleting]
if not done_rows:
return
# Start a batch so the whole sweep is one undo + one toast.
self._del_batch = []
self._del_remaining = len(done_rows)
for r in done_rows:
r._begin_delete()
# Motion off: _begin_delete finalises synchronously; motion on: each row
# melts away and finalises itself, the batch closing on the last one.
# ---- filtering ----
def _matches(self, task):
if self.filter == FILTER_OPEN:
return not task.done
if self.filter == FILTER_DONE:
return task.done
return True
def _set_filter(self, idx):
self.filter = idx
for i, chip in enumerate(self.chips):
chip.setChecked(i == idx)
self.apply_filter()
def apply_filter(self):
any_visible = False
for row in self.rows:
if row._deleting:
# leave a tearing row alone so its wilt animation can finish
any_visible = any_visible or row.isVisible()
continue
vis = self._matches(row.task)
row.setVisible(vis)
any_visible = any_visible or vis
self.empty.setVisible(not any_visible)
def _refresh_empty(self):
any_visible = any(r.isVisible() and not r._deleting for r in self.rows)
self.empty.setVisible(not any_visible)
# ---- footer ----
def _update_footer(self):
total = len(self.store.tasks)
done = sum(1 for t in self.store.tasks if t.done)
remaining = total - done
if total == 0:
self.tally.setText("No rides yet")
elif remaining == 0:
self.tally.setText("All %d rides ridden! ★" % total)
else:
self.tally.setText("%d of %d rides remaining" % (remaining, total))
self.progress.set_ratio(done / total if total else 0.0)
all_done = total > 0 and remaining == 0
if all_done and not self._was_all_done:
self.mascot.celebrate()
self._was_all_done = all_done
# ---- misc ----
def _toggle_calm(self):
clock.calm = not clock.calm
self.calm_btn.setChecked(clock.calm)
self.calm_btn.setText("WAKE" if clock.calm else "CALM")
self.backdrop.set_calm(clock.calm)
def _on_escape(self):
if self.entry.hasFocus() and self.entry.text():
self.entry.clear()
else:
self.entry.clearFocus()
# =============================================================================
# FONT RESOLUTION + ENTRY POINT
# =============================================================================
def _resolve_fonts():
global DISPLAY_FAMILY, BODY_FAMILY
fams = list(QFontDatabase.families())
def pick(prefs):
# exact match first, then a case-insensitive prefix match so we also
# catch Qt's decorated names like "URW Bookman [urw]"
for want in prefs:
for f in fams:
if f == want:
return f
wl = want.lower()
for f in fams:
if f.lower().startswith(wl):
return f
return prefs[-1]
DISPLAY_FAMILY = pick(["URW Bookman", "Bookman Old Style", "Bookman",
"Cooper Black", "URW Gothic", "DejaVu Serif", "Serif"])
BODY_FAMILY = pick(["Quicksand", "Nunito", "Baloo 2", "Clear Sans",
"DejaVu Sans", "Sans Serif"])
def _make_app():
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
app = QApplication(sys.argv)
app.setApplicationName("Quackadero")
app.setOrganizationName("Quackadero")
_resolve_fonts()
global clock
clock = Clock()
return app
def _screenshot(path):
"""Headless self-test: render several states to a contact sheet PNG."""
app = _make_app()
win = Quackadero()
# seed some sample rides so the shot isn't empty
if not win.store.tasks:
for txt, done in [("Ride the Tilt-a-Whirl", True),
("Visit the duck mind-reader", False),
("Buy cotton candy for Quasi", False),
("Win the rubber-hose limbo", True),
("Escape the house of mirrors", False)]:
win.store.tasks.append(Task(text=txt, done=done, created=0.0))
win._rebuild_rows()
win.resize(540, 760)
win.show()
# advance the clock a few frames so animations settle
for _ in range(8):
clock.phase += 0.05
clock.tick.emit()
app.processEvents()
win.grab().save(path)
print("wrote", path)
def main():
if len(sys.argv) >= 2 and sys.argv[1] == "--screenshot":
out = sys.argv[2] if len(sys.argv) >= 3 else "quackadero.png"
_screenshot(out)
return
app = _make_app()
win = Quackadero()
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