#!/usr/bin/env python3
"""
Pendu for Ubuntu
=================
Charms that hang from a rope at the top of your screen and swing with real
physics. Drag them, fling them, let them sway. Right-click a charm for options.

Collections
    Classic      cat, heart, star, moon, bell, orb, leaf  (recolourable)
    Protection   nazar, hamsa, nimbu-mirchi, drishti bommai
    Divine       om coin, vel, temple bell
    Keepsakes    dream catcher, daruma
    Creatures    spider, cobweb
    Your own     any PNG / JPG / SVG you choose

Rope styles: golden thread, silver chain, neon glow, plain cord.
Sizes: small, medium, large, huge.

Requirements (preinstalled on standard Ubuntu desktops):
    sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-3.0

Run:
    python3 pendu.py              start it
    python3 pendu.py --install    add Pendu to your app menu
    python3 pendu.py --uninstall  remove app-menu and login entries
    python3 pendu.py --reset      forget saved settings

Controls:
    Drag the charm       pick it up and swing it
    Double-click charm   give it a spin
    Right-click charm    charm, colour, rope, size, length, position, quit

Everything outside the charm is click-through, so it never blocks your work.
Settings live in ~/.config/pendu/config.json.
"""

import os
import sys

# GNOME on Wayland does not let apps position windows or stay on top.
# Running through XWayland restores both. Must be set before GTK loads.
if os.environ.get("DISPLAY"):
    os.environ.setdefault("GDK_BACKEND", "x11")

import argparse
import colorsys
import fcntl
import json
import math
import random

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_foreign("cairo")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk  # noqa: E402
import cairo  # noqa: E402

try:
    gi.require_version("Pango", "1.0")
    gi.require_version("PangoCairo", "1.0")
    from gi.repository import Pango, PangoCairo  # noqa: E402
    HAVE_PANGO = True
except (ValueError, ImportError):
    HAVE_PANGO = False

APP_NAME = "Pendu"
CONFIG_DIR = os.path.join(GLib.get_user_config_dir(), "pendu")
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
APPS_DIR = os.path.join(GLib.get_user_data_dir(), "applications")
AUTOSTART_DIR = os.path.join(GLib.get_user_config_dir(), "autostart")
DESKTOP_NAME = "pendu.desktop"

# ------------------------------------------------------------ settings ----

COLLECTIONS = [
    ("Classic", ["cat", "heart", "star", "moon", "bell", "orb", "leaf"]),
    ("Protection", ["nazar", "hamsa", "nimbu_mirchi", "drishti"]),
    ("Divine", ["om", "vel", "temple_bell"]),
    ("Keepsakes", ["dream_catcher", "daruma"]),
    ("Creatures", ["spider", "web"]),
]
ALL_CHARMS = [k for _, kinds in COLLECTIONS for k in kinds] + ["custom"]
CLASSIC = set(COLLECTIONS[0][1])

LABELS = {
    "nazar": "Nazar (evil eye)", "hamsa": "Hamsa", "nimbu_mirchi": "Nimbu-mirchi",
    "drishti": "Drishti Bommai", "om": "Om coin", "vel": "Vel",
    "temple_bell": "Temple bell", "dream_catcher": "Dream catcher",
    "daruma": "Daruma", "spider": "Spider", "web": "Cobweb",
    "custom": "My image",
}

COLOURS = {                    # (light, dark) — applies to Classic charms
    "rose":   ((0.98, 0.56, 0.64), (0.70, 0.14, 0.30)),
    "gold":   ((1.00, 0.87, 0.48), (0.74, 0.50, 0.10)),
    "mint":   ((0.62, 0.95, 0.82), (0.10, 0.52, 0.42)),
    "sky":    ((0.62, 0.84, 1.00), (0.12, 0.38, 0.74)),
    "lilac":  ((0.84, 0.74, 1.00), (0.42, 0.26, 0.72)),
    "silver": ((0.95, 0.96, 0.98), (0.48, 0.52, 0.58)),
}

ROPES = {"golden": "Golden thread", "silver": "Silver chain",
         "neon": "Neon glow", "cord": "Plain cord"}
SIZES = {"small": 0.8, "medium": 1.0, "large": 1.25, "huge": 1.5}
LENGTHS = {"short": 150, "medium": 230, "long": 330}
POSITIONS = {"left corner": 0.03, "left": 0.15, "centre": 0.50,
             "right": 0.85, "right corner": 0.97}

BASE_R = 22.0   # charm radius at "medium"
PAD = 70        # window margin around the swing area

DEFAULTS = {
    "charm": "drishti",
    "colour": "gold",
    "rope": "golden",
    "size": "medium",
    "length": "medium",
    "position": "right corner",
    "autostart": False,
    "custom_image": "",
}

# ------------------------------------------------------------- physics ----

STEP = 1.0 / 120.0      # fixed physics step
GRAVITY = 1900.0
AIR = 0.996             # velocity kept per step
ITERATIONS = 24         # constraint passes per step
MAX_STRETCH = 1.02      # rope never exceeds this multiple of its rest length


class Rope:
    """Verlet rope, fixed at the top, with a heavier charm on the end."""

    def __init__(self, ax, ay, length, segments=16):
        self.ax, self.ay = ax, ay
        self.length = length
        self.n = segments
        self.seg = length / segments
        self.p = [[ax, ay + i * self.seg] for i in range(self.n + 1)]
        self.o = [pt[:] for pt in self.p]
        # inverse mass: anchor fixed, rope light, charm four times heavier
        self.inv = [0.0] + [1.0] * (self.n - 1) + [0.25]
        self.held = False

    def step(self, dt, wind=0.0):
        dt2 = dt * dt
        for i in range(1, self.n + 1):
            if self.held and i == self.n:
                continue
            x, y = self.p[i]
            ox, oy = self.o[i]
            vx = (x - ox) * AIR
            vy = (y - oy) * AIR
            self.o[i] = [x, y]
            push = wind if i == self.n else wind * 0.25
            self.p[i] = [x + vx + push * dt2, y + vy + GRAVITY * dt2]

        self.p[0] = [self.ax, self.ay]

        for _ in range(ITERATIONS):
            for i in range(self.n):
                a, b = self.p[i], self.p[i + 1]
                dx, dy = b[0] - a[0], b[1] - a[1]
                d = math.hypot(dx, dy) or 1e-6
                wa = self.inv[i]
                wb = 0.0 if (self.held and i + 1 == self.n) else self.inv[i + 1]
                w = wa + wb
                if w == 0.0:
                    continue
                k = (d - self.seg) / d
                a[0] += dx * k * wa / w
                a[1] += dy * k * wa / w
                b[0] -= dx * k * wb / w
                b[1] -= dy * k * wb / w

        self._clamp()

    def _clamp(self):
        """Hard limit: no point further from the anchor than its share of rope."""
        for i in range(1, self.n + 1):
            limit = i * self.seg * MAX_STRETCH
            dx = self.p[i][0] - self.ax
            dy = self.p[i][1] - self.ay
            d = math.hypot(dx, dy)
            if d > limit:
                s = limit / d
                self.p[i][0] = self.ax + dx * s
                self.p[i][1] = self.ay + dy * s
            if self.p[i][1] < self.ay + 2:
                self.p[i][1] = self.ay + 2

    def hold(self, x, y):
        dx, dy = x - self.ax, y - self.ay
        d = math.hypot(dx, dy)
        reach = self.length * 0.995
        if d > reach:
            x = self.ax + dx / d * reach
            y = self.ay + dy / d * reach
        self.o[-1] = self.p[-1][:]
        self.p[-1] = [x, max(y, self.ay + 2)]

    def release(self):
        self.held = False
        vx = self.p[-1][0] - self.o[-1][0]
        vy = self.p[-1][1] - self.o[-1][1]
        v = math.hypot(vx, vy)
        cap = 45.0
        if v > cap:
            self.o[-1][0] = self.p[-1][0] - vx / v * cap
            self.o[-1][1] = self.p[-1][1] - vy / v * cap

    def kick(self):
        side = random.choice((-1, 1))
        self.o[-1][0] -= side * random.uniform(18, 28)
        self.o[-1][1] += random.uniform(4, 10)

    def end_angle(self):
        (px, py), (ex, ey) = self.p[-2], self.p[-1]
        return math.atan2(ey - py, ex - px) - math.pi / 2


# ============================================================ CHARM ART ====
# Every charm is drawn centred on (0, 0) at radius r. Each has a silhouette
# (used for the drop shadow) and an art function (the charm itself).

TAU = 2 * math.pi
GOLD = ((1.00, 0.88, 0.50), (0.66, 0.44, 0.10))
BRASS = ((1.00, 0.83, 0.42), (0.56, 0.34, 0.06))
INK = (0.08, 0.06, 0.08)


def ellipse(cr, x, y, rx, ry):
    cr.new_sub_path()
    cr.save()
    cr.translate(x, y)
    cr.scale(rx, ry)
    cr.arc(0, 0, 1, 0, TAU)
    cr.restore()


def rrect(cr, x, y, w, h, rad):
    rad = min(rad, w / 2, h / 2)
    cr.new_sub_path()
    cr.arc(x + w - rad, y + rad, rad, -math.pi / 2, 0)
    cr.arc(x + w - rad, y + h - rad, rad, 0, math.pi / 2)
    cr.arc(x + rad, y + h - rad, rad, math.pi / 2, math.pi)
    cr.arc(x + rad, y + rad, rad, math.pi, 1.5 * math.pi)
    cr.close_path()


def shade(r, light, dark, cx=-0.35, cy=-0.40, spread=1.35, ox=0.0, oy=0.0):
    g = cairo.RadialGradient(ox + cx * r, oy + cy * r, 0, ox, oy, spread * r)
    g.add_color_stop_rgb(0.0, *light)
    g.add_color_stop_rgb(1.0, *dark)
    return g


def gloss(cr, x, y, rx, ry, alpha=0.4):
    cr.new_path()
    ellipse(cr, x, y, rx, ry)
    cr.set_source_rgba(1, 1, 1, alpha)
    cr.fill()


def stroke(cr, rgb, width, alpha=1.0):
    cr.set_source_rgba(rgb[0], rgb[1], rgb[2], alpha)
    cr.set_line_width(width)
    cr.set_line_cap(cairo.LINE_CAP_ROUND)
    cr.set_line_join(cairo.LINE_JOIN_ROUND)
    cr.stroke()


def fill(cr, rgb, alpha=1.0):
    cr.set_source_rgba(rgb[0], rgb[1], rgb[2], alpha)
    cr.fill()


# ---- classic ------------------------------------------------------------

def classic_shape(cr, kind, r):
    """Build and fill a classic outline with whatever source is set."""
    if kind == "cat":
        cr.new_path()
        for side in (-1, 1):
            cr.move_to(side * 0.88 * r, -0.30 * r)
            cr.line_to(side * 0.66 * r, -1.02 * r)
            cr.line_to(side * 0.16 * r, -0.64 * r)
            cr.close_path()
        cr.fill()
        cr.new_path()
        ellipse(cr, 0, 0.10 * r, 0.96 * r, 0.81 * r)
        cr.fill()

    elif kind == "heart":
        s = r / 16.0
        cr.new_path()
        for i in range(73):
            t = i / 72.0 * TAU
            x = 16 * math.sin(t) ** 3
            y = -(13 * math.cos(t) - 5 * math.cos(2 * t)
                  - 2 * math.cos(3 * t) - math.cos(4 * t))
            (cr.move_to if i == 0 else cr.line_to)(x * s, (y - 2.5) * s)
        cr.close_path()
        cr.fill()

    elif kind == "star":
        cr.new_path()
        for i in range(10):
            rad = r if i % 2 == 0 else r * 0.46
            a = -math.pi / 2 + i * math.pi / 5
            cr.line_to(math.cos(a) * rad, math.sin(a) * rad)
        cr.close_path()
        cr.fill()

    elif kind == "moon":
        cr.save()
        cr.new_path()
        cr.arc(0, 0, r, 0, TAU)
        cr.clip()
        cr.new_path()
        cr.rectangle(-r, -r, 2 * r, 2 * r)
        cr.arc(0.45 * r, -0.25 * r, 0.85 * r, 0, TAU)
        cr.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
        cr.fill()
        cr.restore()

    elif kind == "bell":
        cr.new_path()
        cr.move_to(-0.85 * r, 0.6 * r)
        cr.curve_to(-0.75 * r, 0.2 * r, -0.75 * r, -0.85 * r, 0, -0.85 * r)
        cr.curve_to(0.75 * r, -0.85 * r, 0.75 * r, 0.2 * r, 0.85 * r, 0.6 * r)
        cr.close_path()
        cr.fill()
        cr.new_path()
        rrect(cr, -0.95 * r, 0.52 * r, 1.9 * r, 0.2 * r, 0.08 * r)
        cr.fill()
        cr.new_path()
        cr.arc(0, 0.9 * r, 0.18 * r, 0, TAU)
        cr.fill()

    elif kind == "orb":
        cr.new_path()
        cr.arc(0, 0, r, 0, TAU)
        cr.fill()

    elif kind == "leaf":
        cr.new_path()
        cr.move_to(0, -r)
        cr.curve_to(0.95 * r, -0.5 * r, 0.7 * r, 0.6 * r, 0, r)
        cr.curve_to(-0.7 * r, 0.6 * r, -0.95 * r, -0.5 * r, 0, -r)
        cr.close_path()
        cr.fill()


CLASSIC_GLOSS = {
    "heart": (-0.38, -0.22, 0.20), "moon": (-0.55, 0.05, 0.14),
    "bell": (-0.30, -0.35, 0.16), "orb": (-0.34, -0.36, 0.30),
    "leaf": (-0.22, -0.30, 0.14), "cat": (-0.46, -0.30, 0.16),
}


def classic_art(cr, kind, r, ctx):
    light, dark = ctx["colour"]
    cr.set_source(shade(r, light, dark))
    classic_shape(cr, kind, r)

    if kind == "cat":
        for side in (-1, 1):                       # inner ears
            cr.new_path()
            cr.move_to(side * 0.72 * r, -0.40 * r)
            cr.line_to(side * 0.63 * r, -0.84 * r)
            cr.line_to(side * 0.32 * r, -0.60 * r)
            cr.close_path()
            fill(cr, (1.0, 0.78, 0.82), 0.75)
        blinking = (ctx["t"] % 4.3) < 0.14
        for side in (-1, 1):                       # eyes
            ex, ey = side * 0.36 * r, 0.02 * r
            cr.new_path()
            if blinking:
                cr.move_to(ex - 0.14 * r, ey)
                cr.curve_to(ex - 0.05 * r, ey + 0.07 * r,
                            ex + 0.05 * r, ey + 0.07 * r, ex + 0.14 * r, ey)
                stroke(cr, INK, max(1.2, 0.07 * r))
            else:
                ellipse(cr, ex, ey, 0.13 * r, 0.17 * r)
                fill(cr, INK, 0.95)
                cr.new_path()
                cr.arc(ex + 0.04 * r, ey - 0.06 * r, 0.05 * r, 0, TAU)
                fill(cr, (1, 1, 1))
        cr.new_path()                              # nose
        cr.move_to(-0.09 * r, 0.24 * r)
        cr.line_to(0.09 * r, 0.24 * r)
        cr.line_to(0, 0.34 * r)
        cr.close_path()
        fill(cr, (0.95, 0.52, 0.60))
        cr.new_path()                              # mouth
        for side in (-1, 1):
            cr.move_to(0, 0.34 * r)
            cr.curve_to(0, 0.44 * r, side * 0.16 * r, 0.46 * r, side * 0.20 * r, 0.38 * r)
        stroke(cr, INK, max(1.0, 0.055 * r), 0.75)
        cr.new_path()                              # whiskers
        for side in (-1, 1):
            for dy in (-0.04, 0.10):
                cr.move_to(side * 0.30 * r, (0.30 + dy * 0.5) * r)
                cr.line_to(side * 1.12 * r, (0.24 + dy * 1.4) * r)
        stroke(cr, (1, 1, 1), max(0.8, 0.04 * r), 0.7)

    if kind == "leaf":
        cr.new_path()
        cr.move_to(0, -0.8 * r)
        cr.curve_to(0.08 * r, -0.2 * r, -0.05 * r, 0.3 * r, 0, 0.85 * r)
        stroke(cr, tuple(c * 0.8 for c in dark), max(1.0, 0.06 * r), 0.8)

    g = CLASSIC_GLOSS.get(kind)
    if g:
        gloss(cr, g[0] * r, g[1] * r, g[2] * r, g[2] * r * 0.7, 0.45)


# ---- protection ---------------------------------------------------------

def eye_disc(cr, r):
    """The nazar eye itself; reused inside the hamsa."""
    cr.new_path()
    cr.arc(0, 0, r, 0, TAU)
    cr.set_source(shade(r, (0.40, 0.62, 0.98), (0.04, 0.16, 0.50)))
    cr.fill()
    cr.new_path()
    cr.arc(0, 0, 0.70 * r, 0, TAU)
    fill(cr, (0.98, 0.98, 1.0))
    cr.new_path()
    cr.arc(0, 0, 0.48 * r, 0, TAU)
    cr.set_source(shade(0.48 * r, (0.60, 0.86, 1.0), (0.18, 0.48, 0.88)))
    cr.fill()
    cr.new_path()
    cr.arc(0, 0, 0.24 * r, 0, TAU)
    fill(cr, INK)
    cr.new_path()
    cr.arc(-0.08 * r, -0.10 * r, 0.08 * r, 0, TAU)
    fill(cr, (1, 1, 1))


def sil_nazar(cr, r, ctx):
    cr.new_path()
    cr.arc(0, 0, r, 0, TAU)
    cr.fill()


def art_nazar(cr, r, ctx):
    eye_disc(cr, r)
    cr.new_path()
    cr.arc(0, 0, r - 0.6, 0, TAU)
    stroke(cr, (0.02, 0.10, 0.35), 1.0, 0.5)
    gloss(cr, -0.40 * r, -0.44 * r, 0.30 * r, 0.15 * r, 0.35)


def hamsa_path(cr, r):
    ellipse(cr, 0, 0.28 * r, 0.60 * r, 0.58 * r)
    for x, top in ((-0.30, -0.70), (0.0, -0.90), (0.30, -0.70)):
        rrect(cr, x * r - 0.13 * r, top * r, 0.26 * r, (0.30 - top) * r, 0.13 * r)
    for side in (-1, 1):
        cr.save()
        cr.translate(side * 0.60 * r, 0.04 * r)
        cr.rotate(side * 0.40)
        ellipse(cr, 0, 0, 0.15 * r, 0.36 * r)
        cr.restore()


def sil_hamsa(cr, r, ctx):
    cr.new_path()
    hamsa_path(cr, r)
    cr.fill()


def art_hamsa(cr, r, ctx):
    cr.new_path()
    hamsa_path(cr, r)
    cr.set_source(shade(r, *GOLD, cy=-0.55))
    cr.fill()
    for x in (-0.15, 0.15):                          # finger separations
        cr.new_path()
        cr.move_to(x * r, -0.62 * r)
        cr.line_to(x * r, -0.12 * r)
        stroke(cr, GOLD[1], max(0.8, 0.03 * r), 0.45)
    for i in range(12):                              # dotted ring
        a = i / 12 * TAU
        cr.new_path()
        cr.arc(math.cos(a) * 0.37 * r, 0.30 * r + math.sin(a) * 0.37 * r,
               0.035 * r, 0, TAU)
        fill(cr, (0.62, 0.10, 0.10), 0.85)
    cr.save()
    cr.translate(0, 0.30 * r)
    eye_disc(cr, 0.25 * r)
    cr.restore()
    gloss(cr, -0.02 * r, -0.62 * r, 0.05 * r, 0.16 * r, 0.40)


def chilli(cr, x, y, ang, length, w):
    cr.save()
    cr.translate(x, y)
    cr.rotate(ang)
    cr.new_sub_path()
    cr.move_to(-w / 2, 0.08 * length)
    cr.curve_to(-w / 2, 0.55 * length, -0.1 * w, 0.85 * length, 0.45 * w, length)
    cr.curve_to(0.35 * w, 0.80 * length, w / 2, 0.50 * length, w / 2, 0.08 * length)
    cr.curve_to(w / 2, -0.03 * length, -w / 2, -0.03 * length, -w / 2, 0.08 * length)
    cr.close_path()
    cr.restore()


def chilli_shine(cr, x, y, ang, length, w):
    cr.save()
    cr.translate(x, y)
    cr.rotate(ang)
    cr.new_path()
    cr.move_to(-0.18 * w, 0.14 * length)
    cr.curve_to(-0.22 * w, 0.40 * length, -0.10 * w, 0.62 * length, 0.05 * w, 0.72 * length)
    cr.restore()
    stroke(cr, (1, 1, 1), max(0.8, 0.22 * w), 0.35)


NIMBU_CHILLIES = ((0.0, 0.0, -0.94), (0.62, -0.14, -0.80), (-0.62, 0.14, -0.80))  # (angle, x, y) / r


def sil_nimbu(cr, r, ctx):
    cr.new_path()
    for ang, dx, dy in NIMBU_CHILLIES:
        chilli(cr, dx * r, dy * r, ang, 0.86 * r, 0.18 * r)
    ellipse(cr, 0, 0.50 * r, 0.47 * r, 0.38 * r)
    cr.fill()


def art_nimbu(cr, r, ctx):
    cr.new_path()                                    # cotton thread
    cr.move_to(0, -1.0 * r)
    cr.line_to(0, 0.50 * r)
    stroke(cr, (0.96, 0.95, 0.90), max(1.0, 0.06 * r))
    for ang, dx, dy in NIMBU_CHILLIES:               # green chillies
        cr.new_path()
        chilli(cr, dx * r, dy * r, ang, 0.86 * r, 0.18 * r)
        g = cairo.LinearGradient(-0.3 * r, -0.9 * r, 0.3 * r, 0.1 * r)
        g.add_color_stop_rgb(0, 0.58, 0.88, 0.32)
        g.add_color_stop_rgb(1, 0.10, 0.44, 0.10)
        cr.set_source(g)
        cr.fill()
        chilli_shine(cr, dx * r, dy * r, ang, 0.86 * r, 0.18 * r)
        cr.new_path()
        ellipse(cr, dx * r, dy * r, 0.08 * r, 0.05 * r)
        fill(cr, (0.14, 0.34, 0.10))
    cr.new_path()                                    # lemon
    ellipse(cr, 0, 0.50 * r, 0.47 * r, 0.38 * r)
    for side in (-1, 1):
        ellipse(cr, side * 0.47 * r, 0.50 * r, 0.08 * r, 0.06 * r)
    g = cairo.RadialGradient(-0.15 * r, 0.36 * r, 0, 0, 0.50 * r, 0.58 * r)
    g.add_color_stop_rgb(0, 1.00, 0.97, 0.60)
    g.add_color_stop_rgb(1, 0.86, 0.70, 0.06)
    cr.set_source(g)
    cr.fill()
    for dx, dy in ((0.12, 0.58), (0.25, 0.48), (-0.05, 0.70), (0.30, 0.66)):
        cr.new_path()
        cr.arc(dx * r, dy * r, 0.02 * r, 0, TAU)
        fill(cr, (0.6, 0.45, 0.0), 0.25)
    gloss(cr, -0.18 * r, 0.38 * r, 0.15 * r, 0.07 * r, 0.55)


def horns(cr, r):
    for side in (-1, 1):
        cr.move_to(side * 0.42 * r, -0.80 * r)
        cr.curve_to(side * 0.55 * r, -1.12 * r, side * 0.78 * r, -1.20 * r,
                    side * 0.92 * r, -1.24 * r)
        cr.curve_to(side * 0.80 * r, -1.00 * r, side * 0.78 * r, -0.82 * r,
                    side * 0.72 * r, -0.60 * r)
        cr.close_path()


def sil_drishti(cr, r, ctx):
    cr.new_path()
    cr.arc(0, 0, r, 0, TAU)
    horns(cr, r)
    cr.fill()


def art_drishti(cr, r, ctx):
    cr.new_path()
    horns(cr, r)
    cr.set_source(shade(r, (0.45, 0.40, 0.36), (0.10, 0.08, 0.08)))
    cr.fill()
    cr.new_path()                                    # face
    cr.arc(0, 0, r, 0, TAU)
    cr.set_source(shade(r, (1.00, 0.58, 0.30), (0.70, 0.08, 0.04)))
    cr.fill()
    cr.new_path()                                    # vibhuti + kumkum
    for y in (-0.82, -0.74, -0.66):
        cr.move_to(-0.22 * r, y * r)
        cr.line_to(0.22 * r, y * r)
    stroke(cr, (1, 1, 1), max(1.0, 0.045 * r), 0.95)
    cr.new_path()
    cr.arc(0, -0.74 * r, 0.055 * r, 0, TAU)
    fill(cr, (0.85, 0.04, 0.10))
    for side in (-1, 1):                             # glaring eyes
        cr.new_path()
        cr.arc(side * 0.36 * r, -0.20 * r, 0.21 * r, 0, TAU)
        fill(cr, (1, 1, 1))
        cr.new_path()
        cr.arc(side * 0.36 * r, -0.17 * r, 0.10 * r, 0, TAU)
        fill(cr, INK)
        cr.new_path()
        cr.arc(side * 0.33 * r, -0.21 * r, 0.03 * r, 0, TAU)
        fill(cr, (1, 1, 1))
        cr.new_path()
        cr.move_to(side * 0.12 * r, -0.40 * r)
        cr.curve_to(side * 0.30 * r, -0.58 * r, side * 0.55 * r, -0.55 * r,
                    side * 0.66 * r, -0.38 * r)
        stroke(cr, INK, max(1.4, 0.10 * r))
    for side in (-1, 1):                             # nostrils
        cr.new_path()
        cr.arc(side * 0.07 * r, 0.10 * r, 0.04 * r, 0, TAU)
        fill(cr, (0.35, 0.02, 0.02))
    cr.new_path()                                    # mouth
    ellipse(cr, 0, 0.52 * r, 0.34 * r, 0.20 * r)
    fill(cr, (0.32, 0.02, 0.05))
    cr.new_path()                                    # tongue
    cr.move_to(-0.13 * r, 0.52 * r)
    cr.line_to(-0.13 * r, 0.86 * r)
    cr.arc_negative(0, 0.86 * r, 0.13 * r, math.pi, 0)
    cr.line_to(0.13 * r, 0.52 * r)
    cr.close_path()
    fill(cr, (0.96, 0.42, 0.52))
    cr.new_path()
    cr.move_to(0, 0.58 * r)
    cr.line_to(0, 0.88 * r)
    stroke(cr, (0.70, 0.15, 0.25), max(0.8, 0.03 * r), 0.8)
    for side in (-1, 1):                             # fangs
        cr.new_path()
        cr.move_to(side * 0.30 * r, 0.40 * r)
        cr.line_to(side * 0.19 * r, 0.40 * r)
        cr.line_to(side * 0.25 * r, 0.64 * r)
        cr.close_path()
        fill(cr, (1, 1, 1))
    for side in (-1, 1):                             # curled moustache
        cr.new_path()
        cr.move_to(0, 0.24 * r)
        cr.curve_to(side * 0.30 * r, 0.10 * r, side * 0.60 * r, 0.22 * r,
                    side * 0.74 * r, 0.04 * r)
        cr.curve_to(side * 0.82 * r, -0.08 * r, side * 0.66 * r, -0.14 * r,
                    side * 0.60 * r, -0.04 * r)
        stroke(cr, INK, max(1.4, 0.09 * r))


# ---- divine -------------------------------------------------------------

_om_choice = {}


def om_layout(cr, r):
    """Tamil Om if a font has it, Devanagari Om otherwise, else None."""
    if not HAVE_PANGO:
        return None
    for glyph in ("\u0bd0", "\u0950"):
        if _om_choice.get(glyph) is False:
            continue
        layout = PangoCairo.create_layout(cr)
        desc = Pango.FontDescription.from_string("Sans Bold")
        desc.set_absolute_size(r * Pango.SCALE)
        layout.set_font_description(desc)
        layout.set_text(glyph, -1)
        if layout.get_unknown_glyphs_count() > 0:
            _om_choice[glyph] = False
            continue
        ink, _ = layout.get_pixel_extents()
        big = max(ink.width, ink.height) or 1
        desc.set_absolute_size(r * (1.08 * r / big) * Pango.SCALE)
        layout.set_font_description(desc)
        _om_choice[glyph] = True
        return layout
    return None


def sil_om(cr, r, ctx):
    cr.new_path()
    cr.arc(0, 0, r, 0, TAU)
    cr.fill()


def art_om(cr, r, ctx):
    cr.new_path()
    cr.arc(0, 0, r, 0, TAU)
    cr.set_source(shade(r, *GOLD))
    cr.fill()
    for i in range(20):                              # beaded rim
        a = i / 20 * TAU
        cr.new_path()
        cr.arc(math.cos(a) * 0.90 * r, math.sin(a) * 0.90 * r, 0.035 * r, 0, TAU)
        fill(cr, (1.0, 0.95, 0.70), 0.9)
    cr.new_path()
    cr.arc(0, 0, 0.80 * r, 0, TAU)
    stroke(cr, GOLD[1], max(1.0, 0.05 * r), 0.7)

    layout = om_layout(cr, r)
    if layout is not None:
        ink, _ = layout.get_pixel_extents()
        x = -ink.x - ink.width / 2
        y = -ink.y - ink.height / 2
        cr.move_to(x + 0.8, y + 0.8)
        cr.set_source_rgba(1.0, 0.94, 0.70, 0.8)
        PangoCairo.show_layout(cr, layout)
        cr.move_to(x, y)
        cr.set_source_rgb(0.46, 0.08, 0.06)
        PangoCairo.show_layout(cr, layout)
    else:                                            # lotus, if no font has Om
        for i in range(5):
            a = -math.pi / 2 + (i - 2) * 0.45
            cr.save()
            cr.translate(0, 0.35 * r)
            cr.rotate(a + math.pi / 2)
            cr.new_path()
            ellipse(cr, 0, -0.38 * r, 0.14 * r, 0.38 * r)
            cr.restore()
            fill(cr, (0.46, 0.08, 0.06), 0.85)
    gloss(cr, -0.40 * r, -0.45 * r, 0.26 * r, 0.12 * r, 0.35)


def vel_head(cr, r):
    cr.move_to(0, -1.0 * r)
    cr.curve_to(0.44 * r, -0.70 * r, 0.44 * r, -0.14 * r, 0, 0.12 * r)
    cr.curve_to(-0.44 * r, -0.14 * r, -0.44 * r, -0.70 * r, 0, -1.0 * r)
    cr.close_path()


def sil_vel(cr, r, ctx):
    cr.new_path()
    vel_head(cr, r)
    rrect(cr, -0.055 * r, 0.10 * r, 0.11 * r, 1.20 * r, 0.05 * r)
    cr.arc(0, 1.32 * r, 0.10 * r, 0, TAU)
    cr.fill()


def art_vel(cr, r, ctx):
    cr.new_path()                                    # shaft
    rrect(cr, -0.055 * r, 0.18 * r, 0.11 * r, 1.12 * r, 0.05 * r)
    g = cairo.LinearGradient(-0.055 * r, 0, 0.055 * r, 0)
    g.add_color_stop_rgb(0.0, 0.52, 0.32, 0.10)
    g.add_color_stop_rgb(0.5, 0.88, 0.66, 0.32)
    g.add_color_stop_rgb(1.0, 0.48, 0.28, 0.08)
    cr.set_source(g)
    cr.fill()
    cr.new_path()
    cr.arc(0, 1.32 * r, 0.10 * r, 0, TAU)
    cr.set_source(shade(0.1 * r, *GOLD, oy=1.32 * r))
    cr.fill()
    cr.new_path()                                    # collar
    rrect(cr, -0.16 * r, 0.06 * r, 0.32 * r, 0.14 * r, 0.04 * r)
    fill(cr, GOLD[1])
    cr.new_path()                                    # spearhead
    vel_head(cr, r)
    cr.set_source(shade(r, *GOLD, cy=-0.6))
    cr.fill_preserve()
    stroke(cr, GOLD[1], max(0.8, 0.035 * r), 0.6)
    cr.new_path()
    cr.move_to(0, -0.92 * r)
    cr.line_to(0, 0.04 * r)
    stroke(cr, (1.0, 0.96, 0.78), max(0.8, 0.035 * r), 0.55)
    cr.new_path()                                    # vibhuti + kumkum
    for y in (-0.46, -0.37, -0.28):
        cr.move_to(-0.22 * r, y * r)
        cr.line_to(0.22 * r, y * r)
    stroke(cr, (1, 1, 1), max(1.0, 0.045 * r), 0.95)
    cr.new_path()
    cr.arc(0, -0.37 * r, 0.055 * r, 0, TAU)
    fill(cr, (0.85, 0.04, 0.10))


def sil_temple_bell(cr, r, ctx):
    cr.new_path()
    cr.arc(0, -0.98 * r, 0.14 * r, 0, TAU)
    cr.fill()
    classic_shape(cr, "bell", r)


def art_temple_bell(cr, r, ctx):
    cr.new_path()                                    # hanging loop
    cr.arc(0, -0.98 * r, 0.12 * r, 0, TAU)
    stroke(cr, BRASS[1], max(1.0, 0.06 * r))
    cr.set_source(shade(r, *BRASS))
    classic_shape(cr, "bell", r)
    cr.new_path()                                    # engraved bands
    cr.move_to(-0.62 * r, -0.22 * r)
    cr.curve_to(-0.2 * r, -0.12 * r, 0.2 * r, -0.12 * r, 0.62 * r, -0.22 * r)
    cr.move_to(-0.80 * r, 0.32 * r)
    cr.curve_to(-0.3 * r, 0.44 * r, 0.3 * r, 0.44 * r, 0.80 * r, 0.32 * r)
    stroke(cr, BRASS[1], max(1.0, 0.05 * r), 0.7)
    for i in range(5):
        cr.new_path()
        cr.arc((-0.36 + i * 0.18) * r, 0.08 * r, 0.035 * r, 0, TAU)
        fill(cr, BRASS[1], 0.7)
    gloss(cr, -0.36 * r, -0.36 * r, 0.09 * r, 0.26 * r, 0.40)


# ---- keepsakes ----------------------------------------------------------

DC_RING = 0.72
DC_FEATHERS = [
    (-0.42, 0.62, ((0.97, 0.74, 0.77), (0.74, 0.34, 0.44))),
    (0.00, 0.78, ((0.72, 0.92, 0.94), (0.22, 0.54, 0.62))),
    (0.42, 0.62, ((1.00, 0.94, 0.80), (0.80, 0.60, 0.38))),
]


def feather(cr, x, y, length, w):
    cr.move_to(x, y)
    cr.curve_to(x + w / 2, y + 0.25 * length, x + w / 2, y + 0.75 * length, x, y + length)
    cr.curve_to(x - w / 2, y + 0.75 * length, x - w / 2, y + 0.25 * length, x, y)
    cr.close_path()


def dc_attach(r, fx):
    rr = DC_RING * r
    return -0.1 * r + math.sqrt(max(0.0, rr * rr - (fx * r) ** 2))


def sil_dream_catcher(cr, r, ctx):
    cr.new_path()
    cr.arc(0, -0.1 * r, DC_RING * r, 0, TAU)
    cr.set_line_width(0.11 * r)
    cr.stroke()
    cr.new_path()
    for fx, length, _ in DC_FEATHERS:
        feather(cr, fx * r, dc_attach(r, fx) + 0.30 * r, length * r, 0.22 * r)
    cr.fill()


def art_dream_catcher(cr, r, ctx):
    cy = -0.1 * r
    rr = DC_RING * r
    pts = [(math.cos(-math.pi / 2 + i * TAU / 8) * 0.66 * rr,
            cy + math.sin(-math.pi / 2 + i * TAU / 8) * 0.66 * rr) for i in range(8)]
    cr.new_path()                                    # web
    for i in range(8):
        cr.move_to(*pts[i])
        cr.line_to(*pts[(i + 3) % 8])
    for i in range(8):
        a = -math.pi / 2 + i * TAU / 8
        cr.move_to(math.cos(a) * rr, cy + math.sin(a) * rr)
        cr.line_to(*pts[i])
    stroke(cr, (0.99, 0.96, 0.88), max(0.6, 0.025 * r), 0.85)
    cr.new_path()
    cr.arc(0, cy, 0.22 * r, 0, TAU)
    stroke(cr, (0.99, 0.96, 0.88), max(0.6, 0.025 * r), 0.85)
    cr.new_path()
    cr.arc(0, cy, 0.07 * r, 0, TAU)
    fill(cr, (0.20, 0.75, 0.74))
    cr.new_path()                                    # wrapped hoop
    cr.arc(0, cy, rr, 0, TAU)
    stroke(cr, (0.72, 0.50, 0.28), max(2.0, 0.11 * r))
    cr.new_path()
    cr.arc(0, cy, rr, 0, TAU)
    cr.set_dash([max(1.5, 0.06 * r), max(1.5, 0.06 * r)])
    stroke(cr, (0.52, 0.32, 0.16), max(1.0, 0.05 * r), 0.8)
    cr.set_dash([])
    for fx, length, (light, dark) in DC_FEATHERS:     # strings, beads, feathers
        x = fx * r
        y0 = dc_attach(r, fx)
        cr.new_path()
        cr.move_to(x, y0)
        cr.line_to(x, y0 + 0.30 * r)
        stroke(cr, (0.95, 0.92, 0.86), max(0.6, 0.025 * r))
        cr.new_path()
        cr.arc(x, y0 + 0.15 * r, 0.055 * r, 0, TAU)
        fill(cr, (0.95, 0.45, 0.35) if fx else (0.20, 0.75, 0.74))
        top = y0 + 0.30 * r
        cr.new_path()
        feather(cr, x, top, length * r, 0.22 * r)
        g = cairo.LinearGradient(x, top, x, top + length * r)
        g.add_color_stop_rgb(0, *light)
        g.add_color_stop_rgb(1, *dark)
        cr.set_source(g)
        cr.fill()
        cr.new_path()
        cr.move_to(x, top)
        cr.line_to(x, top + length * r * 0.96)
        stroke(cr, dark, max(0.6, 0.025 * r), 0.8)


def sil_daruma(cr, r, ctx):
    cr.new_path()
    ellipse(cr, 0, 0.05 * r, 0.92 * r, r)
    cr.fill()


def art_daruma(cr, r, ctx):
    cr.new_path()
    ellipse(cr, 0, 0.05 * r, 0.92 * r, r)
    cr.set_source(shade(r, (1.00, 0.36, 0.30), (0.58, 0.04, 0.05)))
    cr.fill()
    cr.new_path()                                    # face
    ellipse(cr, 0, -0.12 * r, 0.56 * r, 0.46 * r)
    cr.set_source_rgb(1.0, 0.96, 0.88)
    cr.fill_preserve()
    stroke(cr, (0.85, 0.66, 0.24), max(0.8, 0.035 * r))
    for side in (-1, 1):                             # eyes: one filled, one waiting
        cr.new_path()
        cr.arc(side * 0.20 * r, -0.14 * r, 0.12 * r, 0, TAU)
        fill(cr, (1, 1, 1))
        cr.new_path()
        cr.arc(side * 0.20 * r, -0.14 * r, 0.12 * r, 0, TAU)
        stroke(cr, INK, max(0.8, 0.03 * r))
    cr.new_path()
    cr.arc(-0.20 * r, -0.14 * r, 0.085 * r, 0, TAU)
    fill(cr, INK)
    for side in (-1, 1):                             # brows and moustache
        cr.new_path()
        cr.move_to(side * 0.08 * r, -0.34 * r)
        cr.curve_to(side * 0.20 * r, -0.46 * r, side * 0.38 * r, -0.42 * r,
                    side * 0.46 * r, -0.30 * r)
        stroke(cr, INK, max(1.2, 0.07 * r))
        cr.new_path()
        cr.move_to(0, 0.06 * r)
        cr.curve_to(side * 0.15 * r, 0.00, side * 0.30 * r, 0.10 * r, side * 0.42 * r, 0.00)
        stroke(cr, (0.25, 0.22, 0.22), max(1.0, 0.05 * r))
    cr.new_path()                                    # beard
    for i in range(5):
        x = (-0.24 + i * 0.12) * r
        cr.move_to(x, 0.14 * r)
        cr.line_to(x * 0.9, 0.24 * r)
    stroke(cr, (0.35, 0.32, 0.32), max(0.8, 0.03 * r), 0.6)
    cr.new_path()                                    # gold belly emblem
    cr.arc(0, 0.64 * r, 0.20 * r, 0, TAU)
    stroke(cr, (1.0, 0.82, 0.30), max(1.0, 0.05 * r))
    cr.new_path()
    cr.arc(0, 0.64 * r, 0.09 * r, 0, 1.5 * math.pi)
    stroke(cr, (1.0, 0.82, 0.30), max(1.0, 0.04 * r))
    gloss(cr, -0.52 * r, -0.46 * r, 0.12 * r, 0.22 * r, 0.30)


# ---- creatures ----------------------------------------------------------

SPIDER_BODY = (0.34, 0.31, 0.42), (0.05, 0.04, 0.08)


def spider_legs(cr, r):
    for side in (-1, 1):
        for i in range(4):
            oy = (-0.34 + i * 0.15) * r
            ox = side * 0.22 * r
            kx = side * (0.72 + 0.04 * i) * r
            ky = oy - (0.34 - 0.12 * i) * r
            fx = side * (1.02 - 0.06 * i) * r
            fy = oy + (0.30 + 0.12 * i) * r
            cr.move_to(ox, oy)
            cr.curve_to(ox + (kx - ox) * 0.5, oy - 0.10 * r, kx, ky, kx, ky)
            cr.curve_to(kx + (fx - kx) * 0.4, ky, fx, fy - 0.15 * r, fx, fy)


def spider_body(cr, r):
    ellipse(cr, 0, 0.24 * r, 0.54 * r, 0.60 * r)
    ellipse(cr, 0, -0.42 * r, 0.38 * r, 0.32 * r)


def sil_spider(cr, r, ctx):
    cr.new_path()
    spider_legs(cr, r)
    cr.set_line_width(max(1.6, 0.10 * r))
    cr.set_line_cap(cairo.LINE_CAP_ROUND)
    cr.set_line_join(cairo.LINE_JOIN_ROUND)
    cr.stroke()
    cr.new_path()
    spider_body(cr, r)
    cr.fill()


def art_spider(cr, r, ctx):
    wiggle = math.sin(ctx["t"] * 3.1) * 0.05           # legs twitch a little
    cr.save()
    cr.rotate(wiggle)
    cr.new_path()
    spider_legs(cr, r)
    stroke(cr, SPIDER_BODY[1], max(1.6, 0.10 * r))
    cr.new_path()
    spider_legs(cr, r)
    stroke(cr, SPIDER_BODY[0], max(0.6, 0.035 * r), 0.8)
    cr.restore()

    cr.new_path()                                    # abdomen
    ellipse(cr, 0, 0.24 * r, 0.54 * r, 0.60 * r)
    cr.set_source(shade(r, *SPIDER_BODY, oy=0.24 * r))
    cr.fill()
    for dy in (0.10, 0.30, 0.50):                    # little lilac dots
        cr.new_path()
        cr.arc(0, dy * r, 0.055 * r, 0, TAU)
        fill(cr, (0.72, 0.62, 0.98), 0.85)
    cr.new_path()                                    # head
    ellipse(cr, 0, -0.42 * r, 0.38 * r, 0.32 * r)
    cr.set_source(shade(0.4 * r, *SPIDER_BODY, oy=-0.42 * r))
    cr.fill()

    blinking = (ctx["t"] % 5.1) < 0.14
    for side in (-1, 1):                             # big friendly eyes
        ex, ey = side * 0.15 * r, -0.44 * r
        if blinking:
            cr.new_path()
            cr.move_to(ex - 0.10 * r, ey)
            cr.curve_to(ex - 0.04 * r, ey + 0.05 * r, ex + 0.04 * r, ey + 0.05 * r,
                        ex + 0.10 * r, ey)
            stroke(cr, (1, 1, 1), max(1.0, 0.05 * r))
        else:
            cr.new_path()
            cr.arc(ex, ey, 0.13 * r, 0, TAU)
            fill(cr, (1, 1, 1))
            cr.new_path()
            cr.arc(ex, ey + 0.03 * r, 0.075 * r, 0, TAU)
            fill(cr, INK)
            cr.new_path()
            cr.arc(ex + 0.03 * r, ey, 0.03 * r, 0, TAU)
            fill(cr, (1, 1, 1))
    cr.new_path()                                    # small smile
    cr.move_to(-0.08 * r, -0.26 * r)
    cr.curve_to(-0.03 * r, -0.21 * r, 0.03 * r, -0.21 * r, 0.08 * r, -0.26 * r)
    stroke(cr, (1, 1, 1), max(0.8, 0.035 * r), 0.8)
    gloss(cr, -0.22 * r, 0.00, 0.10 * r, 0.20 * r, 0.30)


WEB_SPOKES = 10
WEB_RINGS = (0.24, 0.42, 0.60, 0.78, 0.96)


def web_path(cr, r):
    pts = []
    for i in range(WEB_SPOKES):
        a = -math.pi / 2 + i * TAU / WEB_SPOKES
        pts.append((math.cos(a), math.sin(a)))
        cr.move_to(0, 0)
        cr.line_to(math.cos(a) * r, math.sin(a) * r)
    for ring in WEB_RINGS:                           # threads sag between spokes
        for i in range(WEB_SPOKES):
            (x0, y0), (x1, y1) = pts[i], pts[(i + 1) % WEB_SPOKES]
            mx, my = (x0 + x1) / 2 * ring * 0.86, (y0 + y1) / 2 * ring * 0.86
            cr.move_to(x0 * ring * r, y0 * ring * r)
            cr.curve_to(mx * r, my * r, mx * r, my * r, x1 * ring * r, y1 * ring * r)


def sil_web(cr, r, ctx):
    cr.new_path()
    web_path(cr, r)
    cr.set_line_width(max(1.2, 0.05 * r))
    cr.stroke()


def art_web(cr, r, ctx):
    cr.new_path()
    web_path(cr, r)
    stroke(cr, (0.94, 0.96, 1.0), max(0.7, 0.03 * r), 0.92)
    t = ctx["t"]
    drops = ((0, 0.60), (3, 0.78), (6, 0.42), (8, 0.96), (4, 0.24))
    for i, (spoke, ring) in enumerate(drops):         # dew drops that glint
        a = -math.pi / 2 + spoke * TAU / WEB_SPOKES
        x, y = math.cos(a) * ring * r, math.sin(a) * ring * r
        glint = 0.55 + 0.45 * math.sin(t * 1.7 + i * 1.3)
        cr.new_path()
        cr.arc(x, y, 0.065 * r, 0, TAU)
        cr.set_source(shade(0.065 * r, (0.92, 0.98, 1.0), (0.40, 0.62, 0.86), ox=x, oy=y))
        cr.fill()
        cr.new_path()
        cr.arc(x - 0.02 * r, y - 0.02 * r, 0.022 * r, 0, TAU)
        fill(cr, (1, 1, 1), glint)


# ---- your own image ----------------------------------------------------

def sil_custom(cr, r, ctx):
    img = ctx.get("img")
    if img is not None:
        cr.mask_surface(img, -img.get_width() / 2, -img.get_height() / 2)


def art_custom(cr, r, ctx):
    img = ctx.get("img")
    if img is not None:
        cr.set_source_surface(img, -img.get_width() / 2, -img.get_height() / 2)
        cr.paint()


# ---- registry ----------------------------------------------------------

SILHOUETTE = {
    "nazar": sil_nazar, "hamsa": sil_hamsa, "nimbu_mirchi": sil_nimbu,
    "drishti": sil_drishti, "om": sil_om, "vel": sil_vel,
    "temple_bell": sil_temple_bell, "dream_catcher": sil_dream_catcher,
    "daruma": sil_daruma, "spider": sil_spider, "web": sil_web,
    "custom": sil_custom,
}
ART = {
    "nazar": art_nazar, "hamsa": art_hamsa, "nimbu_mirchi": art_nimbu,
    "drishti": art_drishti, "om": art_om, "vel": art_vel,
    "temple_bell": art_temple_bell, "dream_catcher": art_dream_catcher,
    "daruma": art_daruma, "spider": art_spider, "web": art_web,
    "custom": art_custom,
}
TOPS = {                       # height of the attachment point above centre
    "cat": 0.70, "heart": 0.42, "bell": 0.85, "hamsa": 0.90,
    "nimbu_mirchi": 1.00, "drishti": 1.00, "vel": 1.00,
    "temple_bell": 1.10, "dream_catcher": 0.88, "daruma": 0.95,
    "spider": 0.74, "web": 1.00,
}


def charm_top(kind, r, ctx):
    if kind == "custom" and ctx.get("img") is not None:
        return ctx["img"].get_height() / 2
    return TOPS.get(kind, 1.0) * r


def draw_silhouette(cr, kind, r, ctx):
    if kind in CLASSIC:
        classic_shape(cr, kind, r)
    else:
        SILHOUETTE[kind](cr, r, ctx)


def draw_art(cr, kind, r, ctx):
    if kind in CLASSIC:
        classic_art(cr, kind, r, ctx)
    else:
        ART[kind](cr, r, ctx)

# ========================================================= END CHARM ART ====


# ---------------------------------------------------------------- ropes ----

def rope_path(cr, pts, dx=0.0, dy=0.0):
    """Smooth curve through the rope points (quadratic midpoints as cubics)."""
    cr.new_path()
    cx, cy = pts[0][0] + dx, pts[0][1] + dy
    cr.move_to(cx, cy)
    for i in range(1, len(pts) - 1):
        qx, qy = pts[i][0] + dx, pts[i][1] + dy
        ex = (pts[i][0] + pts[i + 1][0]) / 2 + dx
        ey = (pts[i][1] + pts[i + 1][1]) / 2 + dy
        cr.curve_to(cx + 2 / 3 * (qx - cx), cy + 2 / 3 * (qy - cy),
                    ex + 2 / 3 * (qx - ex), ey + 2 / 3 * (qy - ey), ex, ey)
        cx, cy = ex, ey
    cr.line_to(pts[-1][0] + dx, pts[-1][1] + dy)


def resample(pts, spacing):
    """Evenly spaced (x, y, angle) samples along the rope polyline."""
    out = []
    carry = 0.0
    for i in range(len(pts) - 1):
        (x0, y0), (x1, y1) = pts[i], pts[i + 1]
        seg = math.hypot(x1 - x0, y1 - y0)
        if seg == 0:
            continue
        ang = math.atan2(y1 - y0, x1 - x0)
        d = carry
        while d < seg:
            k = d / seg
            out.append((x0 + (x1 - x0) * k, y0 + (y1 - y0) * k, ang))
            d += spacing
        carry = d - seg
    return out


def neon_colour(t):
    return colorsys.hsv_to_rgb((t * 0.04) % 1.0, 0.72, 1.0)


def rope_colour(style, t):
    return {
        "golden": (0.80, 0.60, 0.22),
        "silver": (0.70, 0.72, 0.78),
        "neon": neon_colour(t),
        "cord": (0.24, 0.20, 0.17),
    }[style]


def draw_rope(cr, pts, style, t):
    cr.set_line_cap(cairo.LINE_CAP_ROUND)
    cr.set_line_join(cairo.LINE_JOIN_ROUND)

    if style == "silver":
        links = resample(pts, 5.0)
        for pass_ in ("shadow", "edge", "body"):
            for i, (x, y, a) in enumerate(links):
                cr.save()
                if pass_ == "shadow":
                    cr.translate(x + 2, y + 3)
                else:
                    cr.translate(x, y)
                cr.rotate(a)
                cr.new_path()
                if i % 2 == 0:
                    ellipse(cr, 0, 0, 3.3, 2.0)
                else:
                    cr.move_to(-3.0, 0)
                    cr.line_to(3.0, 0)
                cr.restore()
                if pass_ == "shadow":
                    stroke(cr, (0, 0, 0), 1.4, 0.14)
                elif pass_ == "edge":
                    stroke(cr, (0.34, 0.36, 0.42), 2.2)
                else:
                    stroke(cr, (0.90, 0.92, 0.96), 1.1)
        return

    if style == "neon":
        c = neon_colour(t)
        for width, alpha in ((16, 0.05), (10, 0.10), (6, 0.20), (3.2, 0.55)):
            rope_path(cr, pts)
            stroke(cr, c, width, alpha)
        rope_path(cr, pts)
        stroke(cr, tuple(0.55 + 0.45 * v for v in c), 1.4)
        return

    rope_path(cr, pts, 2.0, 4.0)
    stroke(cr, (0, 0, 0), 3.4, 0.16)
    if style == "golden":
        rope_path(cr, pts)
        stroke(cr, (0.76, 0.55, 0.18), 2.4)
        rope_path(cr, pts, -0.5, 0.0)
        stroke(cr, (1.0, 0.92, 0.62), 0.9, 0.75)
    else:
        rope_path(cr, pts)
        stroke(cr, (0.24, 0.20, 0.17), 2.4, 0.95)
        rope_path(cr, pts, -0.5, 0.0)
        stroke(cr, (1, 1, 1), 0.8, 0.22)


# ---------------------------------------------------------------- config ----

def load_config():
    cfg = dict(DEFAULTS)
    try:
        with open(CONFIG_PATH, encoding="utf-8") as f:
            saved = json.load(f)
        for key, value in saved.items():
            if key in cfg:
                cfg[key] = value
    except (OSError, ValueError):
        pass
    checks = {"charm": ALL_CHARMS, "colour": COLOURS, "rope": ROPES,
              "size": SIZES, "length": LENGTHS, "position": POSITIONS}
    for key, allowed in checks.items():
        if cfg[key] not in allowed:
            cfg[key] = DEFAULTS[key]
    if cfg["charm"] == "custom" and not os.path.isfile(cfg.get("custom_image") or ""):
        cfg["charm"] = DEFAULTS["charm"]
    return cfg


def save_config(cfg):
    try:
        os.makedirs(CONFIG_DIR, exist_ok=True)
        with open(CONFIG_PATH, "w", encoding="utf-8") as f:
            json.dump(cfg, f, indent=2)
    except OSError as e:
        print(f"Pendu: could not save settings ({e})", file=sys.stderr)


def desktop_entry():
    script = os.path.abspath(__file__)
    return (
        "[Desktop Entry]\n"
        "Type=Application\n"
        f"Name={APP_NAME}\n"
        "Comment=Charms that swing on your desktop\n"
        f"Exec=python3 \"{script}\"\n"
        "Icon=emblem-favorite\n"
        "Terminal=false\n"
        "Categories=Utility;\n"
        "StartupNotify=false\n"
        "X-GNOME-Autostart-enabled=true\n"
    )


def write_entry(directory):
    os.makedirs(directory, exist_ok=True)
    path = os.path.join(directory, DESKTOP_NAME)
    with open(path, "w", encoding="utf-8") as f:
        f.write(desktop_entry())
    os.chmod(path, 0o755)
    return path


def remove_entry(directory):
    path = os.path.join(directory, DESKTOP_NAME)
    if os.path.exists(path):
        os.remove(path)
        return True
    return False


def load_image_surface(path, r):
    """Scale an image to charm size and return it as a cairo surface."""
    size = int(2.4 * r)
    pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, size, size, True)
    return Gdk.cairo_surface_create_from_pixbuf(pixbuf, 1, None)


# ---------------------------------------------------------------- window ----

class PenduWindow(Gtk.Window):
    def __init__(self, cfg):
        super().__init__(type=Gtk.WindowType.TOPLEVEL)
        self.cfg = cfg
        self.rope = None
        self.r = BASE_R
        self.hang = BASE_R + 10
        self.W = self.H = 0
        self.charm_center = (0.0, 0.0)
        self.grab_offset = (0.0, 0.0)
        self._region_key = None
        self._last = None
        self._acc = 0.0
        self._img = None
        self._img_key = None

        self.set_title(APP_NAME)
        self.set_decorated(False)
        self.set_keep_above(True)
        self.set_skip_taskbar_hint(True)
        self.set_skip_pager_hint(True)
        self.set_accept_focus(False)
        self.set_type_hint(Gdk.WindowTypeHint.UTILITY)
        self.set_app_paintable(True)
        self.stick()

        screen = self.get_screen()
        visual = screen.get_rgba_visual()
        if visual is not None and screen.is_composited():
            self.set_visual(visual)
        else:
            print("Pendu: no compositor found, the background will not be "
                  "transparent.", file=sys.stderr)

        self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK
                        | Gdk.EventMask.BUTTON_RELEASE_MASK
                        | Gdk.EventMask.POINTER_MOTION_MASK)
        self.connect("draw", self.on_draw)
        self.connect("button-press-event", self.on_press)
        self.connect("button-release-event", self.on_release)
        self.connect("motion-notify-event", self.on_motion)
        self.connect("destroy", Gtk.main_quit)

        self.build()
        self.add_tick_callback(self.on_tick)

    # ---- layout -------------------------------------------------------

    def build(self):
        length = LENGTHS[self.cfg["length"]]
        self.r = BASE_R * SIZES[self.cfg["size"]]
        self.hang = self.r + 10
        reach = length + self.hang + 2 * self.r
        self.W = int(2 * reach + 2 * PAD)
        self.H = int(reach + 2 * PAD)
        self.set_default_size(self.W, self.H)
        self.resize(self.W, self.H)
        self.rope = Rope(self.W / 2, 0.0, length)
        self.rope.kick()
        self._region_key = None
        self._img_key = None
        self.place()

    def place(self):
        """Put the rope's anchor at the chosen spot, keeping the window on screen.

        The window can't be pushed past the screen edge (GNOME pulls it back),
        so near a corner the window stays put and the anchor slides inside it.
        """
        display = Gdk.Display.get_default()
        monitor = display.get_primary_monitor() or display.get_monitor(0)
        geom = monitor.get_geometry()
        anchor = geom.x + geom.width * POSITIONS[self.cfg["position"]]

        wx = int(anchor - self.W / 2)
        if self.W >= geom.width:
            wx = geom.x
        else:
            wx = max(geom.x, min(wx, geom.x + geom.width - self.W))
        self.move(wx, geom.y)

        new_ax = anchor - wx
        dx = new_ax - self.rope.ax
        if dx:
            for pt in self.rope.p + self.rope.o:
                pt[0] += dx
            self.rope.ax = new_ax
        self._region_key = None

    def image(self):
        if self.cfg["charm"] != "custom":
            return None
        key = (self.cfg.get("custom_image"), self.r)
        if key != self._img_key:
            self._img_key = key
            try:
                self._img = load_image_surface(key[0], self.r)
            except GLib.Error as e:
                print(f"Pendu: could not load image ({e.message})", file=sys.stderr)
                self._img = None
                self.cfg["charm"] = DEFAULTS["charm"]
                save_config(self.cfg)
        return self._img

    def draw_ctx(self, t):
        return {"colour": COLOURS[self.cfg["colour"]], "t": t, "img": self.image()}

    # ---- loop ---------------------------------------------------------

    def on_tick(self, _widget, clock):
        now = clock.get_frame_time() / 1_000_000.0
        if self._last is None:
            self._last = now
        self._acc += min(now - self._last, 0.05)
        self._last = now

        wind = 150.0 * math.sin(now * 0.9) * math.sin(now * 0.31)
        while self._acc >= STEP:
            self.rope.step(STEP, wind)
            self._acc -= STEP

        self.update_charm_center()
        self.update_input_region()
        self.queue_draw()
        return GLib.SOURCE_CONTINUE

    def update_charm_center(self):
        ex, ey = self.rope.p[-1]
        a = self.rope.end_angle()
        self.charm_center = (ex - math.sin(a) * self.hang, ey + math.cos(a) * self.hang)

    def update_input_region(self):
        """Only the charm catches clicks; everything else passes through."""
        if self.get_window() is None:
            return
        if self.rope.held:
            key = ("all",)
            rect = cairo.RectangleInt(0, 0, self.W, self.H)
        else:
            cx, cy = self.charm_center
            r = self.r * 1.3 + 10
            x, y = int(cx - r), int(cy - r)
            key = (x // 3, y // 3, int(r))
            rect = cairo.RectangleInt(x, y, int(2 * r), int(2 * r))
        if key != self._region_key:
            self._region_key = key
            self.input_shape_combine_region(cairo.Region(rect))

    # ---- drawing ------------------------------------------------------

    def on_draw(self, _widget, cr):
        cr.set_operator(cairo.OPERATOR_SOURCE)
        cr.set_source_rgba(0, 0, 0, 0)
        cr.paint()
        cr.set_operator(cairo.OPERATOR_OVER)

        t = GLib.get_monotonic_time() / 1_000_000.0
        kind = self.cfg["charm"]
        ctx = self.draw_ctx(t)
        style = self.cfg["rope"]
        r = self.r

        draw_rope(cr, self.rope.p, style, t)

        ex, ey = self.rope.p[-1]
        cr.save()
        cr.translate(ex, ey)
        cr.rotate(self.rope.end_angle())

        ring = rope_colour(style, t)
        cr.new_path()
        cr.arc(0, 4.5, 4.0, 0, TAU)
        stroke(cr, ring, 1.8, 0.95)
        top = self.hang - charm_top(kind, r, ctx)
        if top > 8.5:
            cr.new_path()
            cr.move_to(0, 8.5)
            cr.line_to(0, top + 1)
            stroke(cr, ring, 1.6, 0.95)

        cr.translate(0, self.hang)

        cr.save()
        cr.translate(3.0, 5.0)
        cr.set_source_rgba(0, 0, 0, 0.22)
        draw_silhouette(cr, kind, r, ctx)
        cr.restore()

        draw_art(cr, kind, r, ctx)
        cr.restore()
        return False

    # ---- input --------------------------------------------------------

    def hit(self, x, y):
        cx, cy = self.charm_center
        return math.hypot(x - cx, y - cy) <= self.r * 1.2 + 10

    def on_press(self, _widget, event):
        if not self.hit(event.x, event.y):
            return False
        double = getattr(Gdk.EventType, "DOUBLE_BUTTON_PRESS", None) \
            or getattr(Gdk.EventType, "_2BUTTON_PRESS")
        if event.button == 1 and event.type == double:
            self.rope.held = False
            self.rope.kick()
            return True
        if event.button == 1:
            ex, ey = self.rope.p[-1]
            self.grab_offset = (event.x - ex, event.y - ey)
            self.rope.held = True
            self._region_key = None
            return True
        if event.button == 3:
            self.build_menu().popup_at_pointer(event)
            return True
        return False

    def on_motion(self, _widget, event):
        if self.rope.held:
            ox, oy = self.grab_offset
            self.rope.hold(event.x - ox, event.y - oy)
        return True

    def on_release(self, _widget, event):
        if event.button == 1 and self.rope.held:
            self.rope.release()
            self._region_key = None
        return True

    # ---- menu ---------------------------------------------------------

    def build_menu(self):
        menu = Gtk.Menu()
        menu.append(self.charm_menu())
        menu.append(self.radio_submenu("Colour (classic charms)", "colour",
                                       [(k, k.capitalize()) for k in COLOURS]))
        menu.append(self.radio_submenu("Rope", "rope", list(ROPES.items())))
        menu.append(self.radio_submenu("Size", "size",
                                       [(k, k.capitalize()) for k in SIZES]))
        menu.append(self.radio_submenu("Rope length", "length",
                                       [(k, k.capitalize()) for k in LENGTHS]))
        menu.append(self.radio_submenu("Position", "position",
                                       [(k, k.capitalize()) for k in POSITIONS]))
        menu.append(Gtk.SeparatorMenuItem())

        spin = Gtk.MenuItem(label="Give it a spin")
        spin.connect("activate", lambda *_: self.rope.kick())
        menu.append(spin)

        rest = Gtk.MenuItem(label="Hide for 15 minutes")
        rest.connect("activate", lambda *_: self.take_break())
        menu.append(rest)

        auto = Gtk.CheckMenuItem(label="Start at login")
        auto.set_active(bool(self.cfg.get("autostart")))
        auto.connect("toggled", self.on_autostart)
        menu.append(auto)

        menu.append(Gtk.SeparatorMenuItem())
        quit_item = Gtk.MenuItem(label="Quit Pendu")
        quit_item.connect("activate", lambda *_: Gtk.main_quit())
        menu.append(quit_item)

        menu.show_all()
        return menu

    def charm_menu(self):
        item = Gtk.MenuItem(label="Charm")
        sub = Gtk.Menu()
        group = None
        for title, kinds in COLLECTIONS:
            coll = Gtk.MenuItem(label=title)
            coll_menu = Gtk.Menu()
            for kind in kinds:
                label = LABELS.get(kind, kind.capitalize())
                radio = Gtk.RadioMenuItem.new_with_label_from_widget(group, label)
                group = radio
                radio.set_active(self.cfg["charm"] == kind)
                radio.connect("toggled", self.on_choice, "charm", kind)
                coll_menu.append(radio)
            coll.set_submenu(coll_menu)
            sub.append(coll)

        sub.append(Gtk.SeparatorMenuItem())
        if os.path.isfile(self.cfg.get("custom_image") or ""):
            mine = Gtk.RadioMenuItem.new_with_label_from_widget(group, LABELS["custom"])
            mine.set_active(self.cfg["charm"] == "custom")
            mine.connect("toggled", self.on_choice, "charm", "custom")
            sub.append(mine)
        pick = Gtk.MenuItem(label="Choose your own image…")
        pick.connect("activate", lambda *_: self.choose_image())
        sub.append(pick)

        item.set_submenu(sub)
        return item

    def radio_submenu(self, title, key, options):
        item = Gtk.MenuItem(label=title)
        sub = Gtk.Menu()
        group = None
        for value, label in options:
            radio = Gtk.RadioMenuItem.new_with_label_from_widget(group, label)
            group = radio
            radio.set_active(self.cfg[key] == value)
            radio.connect("toggled", self.on_choice, key, value)
            sub.append(radio)
        item.set_submenu(sub)
        return item

    def on_choice(self, item, key, value):
        if not item.get_active() or self.cfg[key] == value:
            return
        self.cfg[key] = value
        save_config(self.cfg)
        if key in ("length", "size"):
            self.build()
        elif key == "position":
            self.place()
            self.rope.kick()
        elif key == "charm":
            self._img_key = None
            self.rope.kick()

    def choose_image(self):
        dialog = Gtk.FileChooserDialog(title="Choose an image for your charm",
                                       action=Gtk.FileChooserAction.OPEN)
        dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                           "Hang it", Gtk.ResponseType.OK)
        images = Gtk.FileFilter()
        images.set_name("Images (PNG, JPG, SVG, WebP)")
        images.add_pixbuf_formats()
        dialog.add_filter(images)
        dialog.set_keep_above(True)
        response = dialog.run()
        path = dialog.get_filename()
        dialog.destroy()
        if response != Gtk.ResponseType.OK or not path:
            return
        try:
            load_image_surface(path, self.r)
        except GLib.Error as e:
            err = Gtk.MessageDialog(message_type=Gtk.MessageType.ERROR,
                                    buttons=Gtk.ButtonsType.OK,
                                    text="That image could not be opened.")
            err.format_secondary_text(e.message)
            err.run()
            err.destroy()
            return
        self.cfg["custom_image"] = path
        self.cfg["charm"] = "custom"
        save_config(self.cfg)
        self._img_key = None
        self.rope.kick()

    def on_autostart(self, item):
        on = item.get_active()
        self.cfg["autostart"] = on
        save_config(self.cfg)
        try:
            if on:
                write_entry(AUTOSTART_DIR)
            else:
                remove_entry(AUTOSTART_DIR)
        except OSError as e:
            print(f"Pendu: could not change login setting ({e})", file=sys.stderr)

    def take_break(self):
        self.hide()

        def come_back():
            self._region_key = None
            self.show_all()
            self.place()
            self.rope.kick()
            return GLib.SOURCE_REMOVE

        GLib.timeout_add_seconds(15 * 60, come_back)


# ------------------------------------------------------------------ main ----

_lock_handle = None


def single_instance():
    global _lock_handle
    path = os.path.join(GLib.get_user_runtime_dir() or "/tmp", "pendu.lock")
    _lock_handle = open(path, "w")
    try:
        fcntl.lockf(_lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        print("Pendu is already running. Right-click the charm to quit it.")
        sys.exit(0)


def main():
    parser = argparse.ArgumentParser(description="Charms that swing on your Ubuntu desktop.")
    parser.add_argument("--install", action="store_true", help="add Pendu to the app menu")
    parser.add_argument("--uninstall", action="store_true", help="remove app menu and login entries")
    parser.add_argument("--reset", action="store_true", help="forget saved settings")
    args = parser.parse_args()

    if args.install:
        path = write_entry(APPS_DIR)
        print(f"Added to your app menu: {path}")
        print("Press the Super key and search for 'Pendu'.")
        return
    if args.uninstall:
        a = remove_entry(APPS_DIR)
        b = remove_entry(AUTOSTART_DIR)
        print("Removed." if (a or b) else "Nothing to remove.")
        return
    if args.reset:
        if os.path.exists(CONFIG_PATH):
            os.remove(CONFIG_PATH)
        print("Settings reset.")
        return

    single_instance()
    cfg = load_config()
    window = PenduWindow(cfg)
    window.show_all()
    try:
        Gtk.main()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
