-
-
Save coleifer/499add15d547e7a935b0290ebec9f66d to your computer and use it in GitHub Desktop.
Sally's vision
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 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