#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ClipKeeper v2.0 - a clipboard history manager for Ubuntu (X11 / Wayland) and Windows.

Features
  * Searchable history of everything you copy (text)
  * Pin clips so they are never trimmed from history
  * Global hotkey (default Ctrl+Alt+V) pops the window up at your mouse cursor
  * System-tray icon with Show / Pause / Quit
  * Single instance: launching it again just brings the window forward
  * Skips clips that password managers flag as secret (KeePassXC, Bitwarden, 1Password ...)
  * History persists across restarts (JSON file)
  * Optional auto-paste into the previous app after picking a clip

New in v2.0
  * Screen modes: Light, Dark, Midnight, Sepia, Black & White, White & Black
  * Accent colours: 12 presets, custom colour picker, or animated Rainbow
  * Font options: interface font + size, preview font + size (live preview)
  * Animations: window fade in/out, new-clip highlight pulse, fading status toasts
  * Settings window (Ctrl+,) and quick keys for theme and font size

Requirements
  Python 3.8+ with Tkinter
    Windows : included with the python.org installer
    Ubuntu  : sudo apt install python3-tk
  Optional (hotkey + tray icon):
    pip install pynput pystray pillow
    Ubuntu tray also needs: sudo apt install python3-gi gir1.2-ayatanaappindicator3-0.1

Usage
  python clipkeeper.py                       start (or bring the running instance forward)
  python clipkeeper.py --hidden              start in background
  python clipkeeper.py --install-autostart   start with login + add app-menu entry
  python clipkeeper.py --remove-autostart

Keys (in the window)
  Type            filter the list           Enter / double-click   copy selected clip
  Down            jump from search to list  Delete                 delete selected clip
  Ctrl+P          pin / unpin               Ctrl+F                 focus search
  Ctrl+,          settings                  Ctrl+T                 next screen mode
  Ctrl+ + / -     bigger / smaller text     Ctrl+0                 reset text size
  Esc             hide window               Ctrl+Q                 quit

Data file (history + settings):
  Windows : %APPDATA%\\clipkeeper\\history.json
  Ubuntu  : ~/.local/share/clipkeeper/history.json
"""

import argparse
import colorsys
import json
import os
import queue
import shutil
import socket
import subprocess
import sys
import threading
import time
import uuid
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk, messagebox, colorchooser

APP_NAME = "ClipKeeper"
APP_VERSION = "2.0"
IPC_PORT = 47653
IPC_MAGIC = b"CLIPKEEPER:SHOW"
IS_WIN = sys.platform.startswith("win")
IS_WAYLAND = bool(os.environ.get("WAYLAND_DISPLAY")) and not IS_WIN

DEFAULTS = {
    "max_items": 300,             # unpinned clips kept (pinned are unlimited)
    "poll_ms": 500,               # clipboard check interval
    "hide_after_copy": True,      # hide window after choosing a clip
    "auto_paste": False,          # send Ctrl+V to the previous app after choosing (needs pynput)
    "hotkey": "<ctrl>+<alt>+v",   # pynput format; "" disables
    "max_item_chars": 1_000_000,  # ignore clips bigger than this
    "save_history": True,         # False = keep history in memory only
    # appearance
    "theme": "light",             # see PALETTES
    "accent": "#2563eb",
    "rainbow": False,             # animated multi-colour accent
    "font_family": "",            # "" = system default
    "font_size": 10,
    "preview_font_family": "",    # "" = system monospace
    "preview_font_size": 10,
    # animation
    "animations": True,
    "anim_speed": "normal",       # slow | normal | fast
}

PALETTES = {
    "light":    {"label": "Light", "bg": "#eef1f5", "surface": "#ffffff", "fg": "#1f2430",
                 "muted": "#6b7280", "border": "#d5dae1", "dark": False},
    "dark":     {"label": "Dark", "bg": "#14161b", "surface": "#1d2027", "fg": "#e6e8ec",
                 "muted": "#9aa1ad", "border": "#2c3039", "dark": True},
    "midnight": {"label": "Midnight", "bg": "#0b1220", "surface": "#111a2e", "fg": "#e2e8f0",
                 "muted": "#94a3b8", "border": "#1f2a44", "dark": True},
    "sepia":    {"label": "Sepia", "bg": "#f3ead8", "surface": "#fbf5e9", "fg": "#3b2f22",
                 "muted": "#7a6a55", "border": "#d9ccb2", "dark": False},
    "bw":       {"label": "Black & White", "bg": "#000000", "surface": "#000000", "fg": "#ffffff",
                 "muted": "#cfcfcf", "border": "#ffffff", "dark": True, "forced_accent": "#ffffff"},
    "wb":       {"label": "White & Black", "bg": "#ffffff", "surface": "#ffffff", "fg": "#000000",
                 "muted": "#303030", "border": "#000000", "dark": False, "forced_accent": "#000000"},
}
THEME_ORDER = ["light", "dark", "midnight", "sepia", "bw", "wb"]

ACCENTS = [
    ("Blue", "#2563eb"), ("Indigo", "#4f46e5"), ("Purple", "#9333ea"), ("Pink", "#db2777"),
    ("Red", "#dc2626"), ("Orange", "#ea580c"), ("Amber", "#d97706"), ("Lime", "#65a30d"),
    ("Green", "#16a34a"), ("Teal", "#0d9488"), ("Cyan", "#0891b2"), ("Slate", "#475569"),
]
SPEEDS = {"slow": 1.7, "normal": 1.0, "fast": 0.55}


# ----------------------------------------------------------------------------- paths
def data_dir():
    if IS_WIN:
        base = os.environ.get("APPDATA") or os.path.expanduser("~")
    else:
        base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
    d = os.path.join(base, "clipkeeper")
    os.makedirs(d, exist_ok=True)
    return d


# ----------------------------------------------------------------------------- helpers
def one_line(text, n=150):
    s = " ".join(text[:800].split())
    return s if len(s) <= n else s[: n - 1] + "\u2026"


def rel_time(ts):
    d = time.time() - ts
    if d < 60:
        return "just now"
    if d < 3600:
        return f"{int(d // 60)}m ago"
    if d < 86400:
        return f"{int(d // 3600)}h ago"
    return time.strftime("%d %b %H:%M", time.localtime(ts))


def hotkey_label(hk):
    return "+".join(p.strip("<>").capitalize() for p in hk.split("+")) if hk else "off"


def hex_rgb(h):
    h = h.lstrip("#")
    if len(h) == 3:
        h = "".join(c * 2 for c in h)
    return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))


def rgb_hex(rgb):
    return "#%02x%02x%02x" % tuple(max(0, min(255, int(round(v)))) for v in rgb)


def mix(a, b, t):
    ra, rb = hex_rgb(a), hex_rgb(b)
    return rgb_hex(tuple(x + (y - x) * t for x, y in zip(ra, rb)))


def luminance(h):
    def lin(c):
        c /= 255
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
    r, g, b = hex_rgb(h)
    return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)


def contrast_fg(h):
    return "#000000" if luminance(h) > 0.42 else "#ffffff"


def valid_hex(h, fallback):
    try:
        hex_rgb(h)
        return h if h.startswith("#") else "#" + h
    except Exception:
        return fallback


def ease_out(t):
    return 1 - (1 - t) ** 3


# ----------------------------------------------------------------------------- storage
class Store:
    def __init__(self, path):
        self.path = path
        self.items = []  # newest first: {id, text, ts, pinned}
        self.settings = dict(DEFAULTS)
        self.load()

    def load(self):
        try:
            with open(self.path, encoding="utf-8") as f:
                data = json.load(f)
            self.settings.update(data.get("settings", {}))
            self.items = [
                i for i in data.get("items", [])
                if isinstance(i, dict) and isinstance(i.get("text"), str) and i.get("id")
            ]
        except FileNotFoundError:
            pass
        except Exception:
            try:  # keep a copy of a corrupted file instead of losing it
                os.replace(self.path, self.path + ".corrupt.bak")
            except OSError:
                pass
        s = self.settings
        if s.get("theme") not in PALETTES:
            s["theme"] = "light"
        s["accent"] = valid_hex(str(s.get("accent", "")), DEFAULTS["accent"])
        if s.get("anim_speed") not in SPEEDS:
            s["anim_speed"] = "normal"

    def save(self):
        items = self.items if self.settings.get("save_history", True) else []
        tmp = self.path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump({"settings": self.settings, "items": items}, f, ensure_ascii=False, indent=1)
        os.replace(tmp, self.path)

    def get(self, iid):
        return next((i for i in self.items if i["id"] == iid), None)

    def add(self, text):
        if not text or not text.strip():
            return False
        if len(text) > int(self.settings["max_item_chars"]):
            return False
        now = time.time()
        for idx, it in enumerate(self.items):
            if it["text"] == text:
                it["ts"] = now
                if idx:
                    self.items.insert(0, self.items.pop(idx))
                return True
        self.items.insert(0, {"id": uuid.uuid4().hex, "text": text, "ts": now, "pinned": False})
        self.trim()
        return True

    def trim(self):
        limit, count, keep = int(self.settings["max_items"]), 0, []
        for it in self.items:
            if it.get("pinned"):
                keep.append(it)
            else:
                count += 1
                if count <= limit:
                    keep.append(it)
        self.items = keep

    def remove(self, iid):
        self.items = [i for i in self.items if i["id"] != iid]

    def toggle_pin(self, iid):
        it = self.get(iid)
        if it:
            it["pinned"] = not it.get("pinned")
            self.trim()

    def clear_unpinned(self):
        self.items = [i for i in self.items if i.get("pinned")]


# ----------------------------------------------------------------------------- clipboard
def _win_set_clipboard(text):
    import ctypes
    from ctypes import wintypes
    CF_UNICODETEXT, GMEM_MOVEABLE = 13, 0x0002
    k32, u32 = ctypes.windll.kernel32, ctypes.windll.user32
    k32.GlobalAlloc.restype = wintypes.HGLOBAL
    k32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
    k32.GlobalLock.restype = wintypes.LPVOID
    k32.GlobalLock.argtypes = [wintypes.HGLOBAL]
    k32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
    u32.SetClipboardData.restype = wintypes.HANDLE
    u32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
    data = text.encode("utf-16-le") + b"\x00\x00"
    h = k32.GlobalAlloc(GMEM_MOVEABLE, len(data))
    p = k32.GlobalLock(h)
    ctypes.memmove(p, data, len(data))
    k32.GlobalUnlock(h)
    for _ in range(20):
        if u32.OpenClipboard(None):
            break
        time.sleep(0.02)
    else:
        raise OSError("clipboard busy")
    try:
        u32.EmptyClipboard()
        if not u32.SetClipboardData(CF_UNICODETEXT, h):
            raise OSError("SetClipboardData failed")
    finally:
        u32.CloseClipboard()


def _win_clip_is_secret():
    try:
        import ctypes
        u32 = ctypes.windll.user32
        fmt = u32.RegisterClipboardFormatW("ExcludeClipboardContentFromMonitorProcessing")
        return bool(fmt and u32.IsClipboardFormatAvailable(fmt))
    except Exception:
        return False


def _linux_set_clipboard(text):
    data = text.encode("utf-8")
    cmds = []
    if IS_WAYLAND:
        cmds.append(["wl-copy"])
    cmds += [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
    for c in cmds:
        if shutil.which(c[0]):
            try:
                subprocess.run(c, input=data, timeout=3, check=True)
                return True
            except Exception:
                continue
    return False


class Clipboard:
    """Reads/writes the system clipboard through Tk, with native fallbacks.
    On Ubuntu Wayland (GNOME) Tk runs through XWayland, which mirrors the clipboard."""

    SECRET_TARGETS = ("x-kde-passwordManagerHint",)

    def __init__(self, root):
        self.root = root

    def is_secret(self):
        if IS_WIN:
            return _win_clip_is_secret()
        try:
            targets = self.root.clipboard_get(type="TARGETS")
            return any(t in targets for t in self.SECRET_TARGETS)
        except Exception:
            return False

    def read(self):
        try:
            if IS_WIN:
                return self.root.clipboard_get()
            try:
                return self.root.clipboard_get(type="UTF8_STRING")
            except tk.TclError:
                return self.root.clipboard_get()
        except Exception:
            return None  # empty, image, or unavailable

    def write(self, text):
        try:
            self.root.clipboard_clear()
            self.root.clipboard_append(text)
            self.root.update_idletasks()
            return True
        except Exception:
            pass
        try:
            if IS_WIN:
                _win_set_clipboard(text)
                return True
            return _linux_set_clipboard(text)
        except Exception:
            return False


# ----------------------------------------------------------------------------- single instance
def send_to_running():
    try:
        with socket.create_connection(("127.0.0.1", IPC_PORT), timeout=0.6) as s:
            s.sendall(IPC_MAGIC)
            s.settimeout(0.6)
            return s.recv(8) == b"OK"
    except OSError:
        return False


def start_ipc_server(q):
    try:
        srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        if not IS_WIN:
            srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        srv.bind(("127.0.0.1", IPC_PORT))
        srv.listen(5)
    except OSError:
        return None

    def loop():
        while True:
            try:
                conn, _ = srv.accept()
            except OSError:
                break
            with conn:
                try:
                    conn.settimeout(1)
                    if conn.recv(64).startswith(IPC_MAGIC):
                        conn.sendall(b"OK")
                        q.put("show")
                except OSError:
                    pass

    threading.Thread(target=loop, daemon=True).start()
    return srv


# ----------------------------------------------------------------------------- icons
def make_tk_icon():
    img = tk.PhotoImage(width=32, height=32)
    img.put("#2563eb", to=(4, 5, 28, 31))
    img.put("#ffffff", to=(8, 9, 24, 27))
    img.put("#1e3a8a", to=(11, 2, 21, 9))
    for y in (13, 17, 21):
        img.put("#94a3b8", to=(11, y, 21, y + 2))
    return img


def make_pil_icon(color="#2563eb"):
    from PIL import Image, ImageDraw
    body = hex_rgb(color)
    clip = hex_rgb(mix(color, "#000000", 0.45))
    im = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
    d = ImageDraw.Draw(im)
    d.rectangle((8, 10, 56, 62), fill=body + (255,))
    d.rectangle((16, 18, 48, 54), fill=(255, 255, 255, 255))
    d.rectangle((22, 4, 42, 18), fill=clip + (255,))
    for y in (26, 34, 42):
        d.rectangle((22, y, 42, y + 3), fill=(148, 163, 184, 255))
    return im


def set_win_titlebar(window, dark):
    """Dark/light native title bar on Windows 10/11 (no-op elsewhere)."""
    if not IS_WIN:
        return
    try:
        import ctypes
        window.update_idletasks()
        hwnd = ctypes.windll.user32.GetParent(window.winfo_id())
        val = ctypes.c_int(1 if dark else 0)
        for attr in (20, 19):
            if ctypes.windll.dwmapi.DwmSetWindowAttribute(
                    hwnd, attr, ctypes.byref(val), ctypes.sizeof(val)) == 0:
                break
    except Exception:
        pass


# ----------------------------------------------------------------------------- app
class App:
    def __init__(self, root, store, start_hidden=False):
        self.root = root
        self.store = store
        self.clip = Clipboard(root)
        self.q = queue.Queue()
        self.paused = False
        self.last_seen = None
        self.dirty = False
        self.icon = None
        self._hk = None
        self._status_msg = None
        self._shown = 0
        self._flash_id = None
        self._flash_tok = 0
        self._fade_tok = 0
        self._toast_tok = 0
        self._hue = 0.0
        self.settings_dialog = None
        self.pal = {}
        self.full_unicode = self._check_unicode()

        self._make_fonts()
        self._build_ui()
        self.apply_fonts()
        self.apply_theme()

        self.srv = start_ipc_server(self.q)
        self.hotkey_ok = self._setup_hotkey()
        self.tray_ok = self._setup_tray()
        # Only fully hide the window if there is a way to bring it back
        self.can_withdraw = self.tray_ok or (self.hotkey_ok and not IS_WAYLAND)

        self.refresh()
        root.protocol("WM_DELETE_WINDOW", self.hide_window)
        root.after(200, self._poll)
        root.after(100, self._process_queue)
        root.after(2000, self._autosave)
        root.after(60000, self._tick)
        root.after(300, self._rainbow_loop)
        if start_hidden:
            root.after(50, lambda: self.hide_window(animate=False))
        else:
            root.after(50, lambda: self.show_window(near_pointer=False))

    # ================================================================ fonts & theme
    def _make_fonts(self):
        base = tkfont.nametofont("TkDefaultFont").actual()
        mono = tkfont.nametofont("TkFixedFont").actual()
        self.default_family = base.get("family", "TkDefaultFont")
        self.default_mono = mono.get("family", "TkFixedFont")
        self.f_ui = tkfont.Font(root=self.root, family=self.default_family, size=10)
        self.f_bold = tkfont.Font(root=self.root, family=self.default_family, size=10, weight="bold")
        self.f_small = tkfont.Font(root=self.root, family=self.default_family, size=9)
        self.f_prev = tkfont.Font(root=self.root, family=self.default_mono, size=10)

    def apply_fonts(self):
        s = self.store.settings
        fam = s.get("font_family") or self.default_family
        size = max(7, min(32, int(s.get("font_size", 10))))
        pfam = s.get("preview_font_family") or self.default_mono
        psize = max(7, min(32, int(s.get("preview_font_size", 10))))
        self.f_ui.configure(family=fam, size=size)
        self.f_bold.configure(family=fam, size=size, weight="bold")
        self.f_small.configure(family=fam, size=max(7, size - 1))
        self.f_prev.configure(family=pfam, size=psize)
        self.style.configure(".", font=self.f_ui)
        self.style.configure("Treeview", font=self.f_ui,
                             rowheight=int(self.f_ui.metrics("linespace") * 1.75))
        self.style.configure("Treeview.Heading", font=self.f_bold)
        self.style.configure("Section.TLabel", font=self.f_bold)
        self.style.configure("Muted.TLabel", font=self.f_small)
        self.style.configure("Status.TLabel", font=self.f_small)
        self.style.configure("Toast.TLabel", font=self.f_small)
        self.tree.tag_configure("pinned", font=self.f_bold)
        self.menu.configure(font=self.f_ui)
        if self.dialog_alive():
            self.settings_dialog.sync()

    def palette(self):
        return PALETTES.get(self.store.settings.get("theme"), PALETTES["light"])

    def apply_theme(self):
        base = self.palette()
        p = self.pal = dict(base)
        p["hover"] = mix(p["surface"], p["fg"], 0.08)
        p["pressed"] = mix(p["surface"], p["fg"], 0.16)
        p["thumb"] = mix(p["surface"], p["fg"], 0.25)
        bg, sf, fg, mu, bd = p["bg"], p["surface"], p["fg"], p["muted"], p["border"]
        st = self.style

        st.configure(".", background=bg, foreground=fg, bordercolor=bd, lightcolor=bg,
                     darkcolor=bg, troughcolor=bg, fieldbackground=sf, insertcolor=fg,
                     selectbackground=mix(sf, fg, 0.2), selectforeground=fg)
        st.configure("TFrame", background=bg)
        st.configure("TLabel", background=bg, foreground=fg)
        st.configure("Section.TLabel", background=bg, foreground=fg)
        st.configure("Muted.TLabel", background=bg, foreground=mu)
        st.configure("Status.TLabel", background=bg, foreground=mu)
        st.configure("Toast.TLabel", background=bg, foreground=mu)
        st.configure("TButton", background=sf, foreground=fg, bordercolor=bd, lightcolor=sf,
                     darkcolor=sf, padding=(10, 4), relief="flat", focuscolor=bd)
        st.map("TButton", background=[("pressed", p["pressed"]), ("active", p["hover"])],
               foreground=[("disabled", mu)])
        st.configure("Icon.TButton", padding=(6, 4))
        for w in ("TCheckbutton", "TRadiobutton"):
            st.configure(w, background=bg, foreground=fg, indicatorbackground=mix(sf, fg, 0.12),
                         indicatorforeground=fg, upperbordercolor=mix(bd, fg, 0.35),
                         lowerbordercolor=mix(bd, fg, 0.35), focuscolor=bg)
        st.configure("TEntry", fieldbackground=sf, foreground=fg, bordercolor=bd,
                     lightcolor=sf, darkcolor=sf, padding=4)
        st.configure("TCombobox", fieldbackground=sf, background=sf, foreground=fg,
                     arrowcolor=fg, bordercolor=bd, lightcolor=sf, darkcolor=sf)
        st.map("TCombobox", fieldbackground=[("readonly", sf)], foreground=[("readonly", fg)],
               selectbackground=[("readonly", sf)], selectforeground=[("readonly", fg)])
        st.configure("TSpinbox", fieldbackground=sf, background=sf, foreground=fg,
                     arrowcolor=fg, bordercolor=bd, lightcolor=sf, darkcolor=sf)
        st.configure("Treeview", background=sf, fieldbackground=sf, foreground=fg,
                     bordercolor=bd, lightcolor=sf, darkcolor=sf)
        st.configure("Treeview.Heading", background=bg, foreground=mu, bordercolor=bd,
                     lightcolor=bg, darkcolor=bg, relief="flat")
        st.map("Treeview.Heading", background=[("active", p["hover"])])
        st.configure("Vertical.TScrollbar", background=p["thumb"], troughcolor=sf,
                     bordercolor=sf, arrowcolor=mu, lightcolor=p["thumb"],
                     darkcolor=p["thumb"], gripcount=0)
        st.map("Vertical.TScrollbar", background=[("active", mix(sf, fg, 0.4))])
        st.configure("TPanedwindow", background=bg)
        st.configure("Sash", sashthickness=6, gripcount=0, background=bg, lightcolor=bg,
                     bordercolor=bg)
        st.configure("TNotebook", background=bg, bordercolor=bd, lightcolor=bg, darkcolor=bg)
        st.configure("TNotebook.Tab", background=bg, foreground=mu, bordercolor=bd,
                     lightcolor=bg, padding=(12, 5))
        st.configure("TLabelframe", background=bg, bordercolor=bd)
        st.configure("TLabelframe.Label", background=bg, foreground=mu)

        self.root.configure(bg=bg)
        self.preview.configure(bg=sf, fg=fg, insertbackground=fg, highlightthickness=1,
                               highlightbackground=bd, font=self.f_prev)
        self.menu.configure(bg=sf, fg=fg, activeborderwidth=0, bd=1)
        for opt, val in (("background", sf), ("foreground", fg),
                         ("selectBackground", mix(sf, fg, 0.2)), ("selectForeground", fg)):
            self.root.option_add(f"*TCombobox*Listbox.{opt}", val)
        self.tree.tag_configure("flash", background=sf)
        set_win_titlebar(self.root, p["dark"])

        accent = p.get("forced_accent") or self.store.settings.get("accent", DEFAULTS["accent"])
        self.apply_accent(accent)
        if self.icon and not p.get("forced_accent"):
            try:
                self.icon.icon = make_pil_icon(accent)
            except Exception:
                pass
        if self.dialog_alive():
            self.settings_dialog.recolor()

    def apply_accent(self, accent):
        """Only restyles accent-coloured parts (cheap enough to animate)."""
        p = self.pal
        afg = contrast_fg(accent)
        p["accent"], p["accent_fg"] = accent, afg
        st = self.style
        st.configure("Accent.TButton", background=accent, foreground=afg, bordercolor=accent,
                     lightcolor=accent, darkcolor=accent)
        st.map("Accent.TButton",
               background=[("pressed", mix(accent, p["fg"], 0.25)),
                           ("active", mix(accent, p["surface"], 0.18))],
               foreground=[("disabled", p["muted"])])
        st.map("Treeview", background=[("selected", accent)], foreground=[("selected", afg)])
        st.map("TEntry", bordercolor=[("focus", accent)], lightcolor=[("focus", accent)])
        st.map("TCombobox", bordercolor=[("focus", accent)])
        st.map("TSpinbox", bordercolor=[("focus", accent)])
        for w in ("TCheckbutton", "TRadiobutton"):
            st.map(w, indicatorbackground=[("selected", accent), ("active", p["hover"])],
                   indicatorforeground=[("selected", afg)], background=[("active", p["bg"])])
        st.map("TNotebook.Tab", background=[("selected", p["surface"])],
               foreground=[("selected", accent)], lightcolor=[("selected", accent)])
        self.accent_bar.configure(bg=accent)
        self.preview.configure(selectbackground=accent, selectforeground=afg,
                               highlightcolor=accent)
        self.menu.configure(activebackground=accent, activeforeground=afg)
        if self.dialog_alive():
            self.settings_dialog.sample_bar.configure(bg=accent)

    def cycle_theme(self, event=None):
        s = self.store.settings
        i = THEME_ORDER.index(s.get("theme", "light")) if s.get("theme") in THEME_ORDER else 0
        s["theme"] = THEME_ORDER[(i + 1) % len(THEME_ORDER)]
        self.mark_dirty()
        self.apply_theme()
        self.flash(f"Screen mode: {PALETTES[s['theme']]['label']}")
        return "break"

    def change_font_size(self, delta):
        s = self.store.settings
        if delta == 0:
            s["font_size"] = DEFAULTS["font_size"]
            s["preview_font_size"] = DEFAULTS["preview_font_size"]
        else:
            s["font_size"] = max(7, min(32, int(s["font_size"]) + delta))
            s["preview_font_size"] = max(7, min(32, int(s["preview_font_size"]) + delta))
        self.mark_dirty()
        self.apply_fonts()
        self.flash(f"Text size: {s['font_size']} pt")
        return "break"

    # ================================================================ UI
    def _check_unicode(self):
        try:
            tk.Label(self.root, text="\U0001F600").destroy()
            return True
        except tk.TclError:
            return False

    def disp(self, s):
        return s if self.full_unicode else "".join(c if ord(c) <= 0xFFFF else "\uFFFD" for c in s)

    def _build_ui(self):
        r = self.root
        r.title(f"{APP_NAME} {APP_VERSION}")
        r.geometry("680x560")
        r.minsize(440, 340)
        self._icon_img = make_tk_icon()
        r.iconphoto(True, self._icon_img)

        self.style = ttk.Style(r)
        if "clam" in self.style.theme_names():
            self.style.theme_use("clam")

        self.accent_bar = tk.Frame(r, height=4, bd=0, highlightthickness=0)
        self.accent_bar.pack(fill="x")

        top = ttk.Frame(r, padding=(8, 8, 8, 4))
        top.pack(fill="x")
        ttk.Label(top, text="Search").pack(side="left", padx=(0, 6))
        self.search_var = tk.StringVar()
        self.search_var.trace_add("write", lambda *_: self.refresh())
        self.search = ttk.Entry(top, textvariable=self.search_var)
        self.search.pack(side="left", fill="x", expand=True)
        self.pause_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(top, text="Pause", variable=self.pause_var,
                        command=lambda: self.set_paused(self.pause_var.get())).pack(side="left", padx=(8, 0))
        ttk.Button(top, text="\u25D0", width=3, style="Icon.TButton",
                   command=self.cycle_theme).pack(side="left", padx=(8, 0))
        ttk.Button(top, text="\u2699 Settings", style="Icon.TButton",
                   command=self.open_settings).pack(side="left", padx=(4, 0))

        pw = ttk.PanedWindow(r, orient="vertical")
        pw.pack(fill="both", expand=True, padx=8)

        tf = ttk.Frame(pw)
        self.tree = ttk.Treeview(tf, columns=("text", "when"), show="headings", selectmode="browse")
        self.tree.heading("text", text="Clip")
        self.tree.heading("when", text="Copied")
        self.tree.column("text", anchor="w", width=480, stretch=True)
        self.tree.column("when", anchor="e", width=110, stretch=False)
        sb = ttk.Scrollbar(tf, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=sb.set)
        self.tree.pack(side="left", fill="both", expand=True)
        sb.pack(side="right", fill="y")
        pw.add(tf, weight=3)

        pf = ttk.Frame(pw)
        self.preview = tk.Text(pf, height=6, wrap="word", relief="flat", padx=8, pady=8,
                               state="disabled", cursor="arrow", bd=0)
        psb = ttk.Scrollbar(pf, orient="vertical", command=self.preview.yview)
        self.preview.configure(yscrollcommand=psb.set)
        self.preview.pack(side="left", fill="both", expand=True, pady=(6, 0))
        psb.pack(side="right", fill="y", pady=(6, 0))
        pw.add(pf, weight=1)

        bar = ttk.Frame(r, padding=(8, 8, 8, 2))
        bar.pack(fill="x")
        ttk.Button(bar, text="Copy", style="Accent.TButton", command=self.copy_selected).pack(side="left")
        ttk.Button(bar, text="Pin / Unpin", command=self.pin_selected).pack(side="left", padx=4)
        ttk.Button(bar, text="Delete", command=self.delete_selected).pack(side="left")
        ttk.Button(bar, text="Clear unpinned", command=self.clear_unpinned).pack(side="left", padx=4)
        ttk.Button(bar, text="Quit", command=self.quit).pack(side="right")

        self.status = ttk.Label(r, padding=(10, 2, 10, 6), anchor="w", style="Status.TLabel")
        self.status.pack(fill="x")

        self.menu = tk.Menu(r, tearoff=0)
        self.menu.add_command(label="Copy", command=self.copy_selected)
        self.menu.add_command(label="Pin / Unpin", command=self.pin_selected)
        self.menu.add_separator()
        self.menu.add_command(label="Delete", command=self.delete_selected)

        # bindings
        self.tree.bind("<<TreeviewSelect>>", lambda e: self.update_preview())
        self.tree.bind("<Double-1>", self.copy_selected)
        self.tree.bind("<Return>", self.copy_selected)
        self.tree.bind("<KP_Enter>", self.copy_selected)
        self.tree.bind("<Delete>", self.delete_selected)
        self.tree.bind("<Button-3>", self._on_right_click)
        self.search.bind("<Down>", self._focus_list)
        self.search.bind("<Return>", self.copy_selected)
        self.search.bind("<KP_Enter>", self.copy_selected)
        r.bind("<Escape>", lambda e: self.hide_window())
        r.bind("<Control-p>", self.pin_selected)
        r.bind("<Control-f>", lambda e: (self.search.focus_set(), self.search.select_range(0, "end")))
        r.bind("<Control-q>", lambda e: self.quit())
        r.bind("<Control-comma>", lambda e: self.open_settings())
        r.bind("<Control-t>", self.cycle_theme)
        for seq in ("<Control-plus>", "<Control-equal>", "<Control-KP_Add>"):
            r.bind(seq, lambda e: self.change_font_size(1))
        for seq in ("<Control-minus>", "<Control-KP_Subtract>"):
            r.bind(seq, lambda e: self.change_font_size(-1))
        r.bind("<Control-0>", lambda e: self.change_font_size(0))

    # ================================================================ list
    def selected_id(self):
        sel = self.tree.selection()
        return sel[0] if sel else None

    def refresh(self):
        sel = self.selected_id()
        q = self.search_var.get().strip().lower()
        self.tree.delete(*self.tree.get_children())
        ordered = [i for i in self.store.items if i.get("pinned")] + \
                  [i for i in self.store.items if not i.get("pinned")]
        shown = 0
        for it in ordered:
            if q and q not in it["text"].lower():
                continue
            pinned = bool(it.get("pinned"))
            tags = []
            if pinned:
                tags.append("pinned")
            if it["id"] == self._flash_id:
                tags.append("flash")
            label = ("\u2605 " if pinned else "") + self.disp(one_line(it["text"]))
            self.tree.insert("", "end", iid=it["id"], values=(label, rel_time(it["ts"])), tags=tags)
            shown += 1
        kids = self.tree.get_children()
        if sel and self.tree.exists(sel):
            self.tree.selection_set(sel)
            self.tree.see(sel)
        elif kids:
            self.tree.selection_set(kids[0])
            self.tree.see(kids[0])
        self.update_preview()
        self._shown = shown
        self.update_status()

    def update_preview(self):
        it = self.store.get(self.selected_id() or "")
        self.preview.configure(state="normal")
        self.preview.delete("1.0", "end")
        if it:
            text = it["text"]
            if len(text) > 20000:
                text = text[:20000] + f"\n\n\u2026 ({len(it['text']):,} characters total)"
            try:
                self.preview.insert("1.0", text)
            except tk.TclError:
                self.preview.insert("1.0", "".join(c if ord(c) <= 0xFFFF else "\uFFFD" for c in text))
        self.preview.configure(state="disabled")

    def update_status(self):
        if self._status_msg:
            self.status.configure(text=self._status_msg)
            return
        parts = [f"{self._shown}/{len(self.store.items)} clips",
                 "Paused" if self.paused else "Monitoring",
                 f"Hotkey: {hotkey_label(self.store.settings.get('hotkey'))}"
                 + ("" if getattr(self, "hotkey_ok", False) else " (unavailable)")]
        self.status.configure(text="   \u00b7   ".join(parts))

    def _focus_list(self, event=None):
        kids = self.tree.get_children()
        if kids:
            self.tree.focus_set()
            cur = self.selected_id() or kids[0]
            self.tree.selection_set(cur)
            self.tree.focus(cur)
        return "break"

    def _on_right_click(self, e):
        iid = self.tree.identify_row(e.y)
        if iid:
            self.tree.selection_set(iid)
            self.tree.focus(iid)
            try:
                self.menu.tk_popup(e.x_root, e.y_root)
            finally:
                self.menu.grab_release()

    # ================================================================ animations
    def anim_on(self):
        return bool(self.store.settings.get("animations", True))

    def speed(self):
        return SPEEDS.get(self.store.settings.get("anim_speed"), 1.0)

    def _get_alpha(self):
        try:
            return float(self.root.attributes("-alpha"))
        except Exception:
            return 1.0

    def _set_alpha(self, a):
        try:
            self.root.attributes("-alpha", max(0.0, min(1.0, a)))
        except Exception:
            pass

    def _fade(self, start, end, ms, then=None):
        self._fade_tok += 1
        tok = self._fade_tok
        steps = max(1, int(ms * self.speed() / 16))

        def step(i):
            if tok != self._fade_tok:
                return
            self._set_alpha(start + (end - start) * ease_out(i / steps))
            if i < steps:
                self.root.after(16, step, i + 1)
            elif then:
                then()
        step(0)

    def flash(self, msg, ms=2400):
        """Status-bar toast that fades from the accent colour back to muted."""
        self._toast_tok += 1
        tok = self._toast_tok
        self._status_msg = msg
        self.status.configure(style="Toast.TLabel")
        self.update_status()

        def done():
            if tok != self._toast_tok:
                return
            self._status_msg = None
            self.status.configure(style="Status.TLabel")
            self.update_status()

        if not self.anim_on():
            self.style.configure("Toast.TLabel", foreground=self.pal.get("accent", self.pal["fg"]))
            self.root.after(ms, done)
            return
        steps = max(1, int(ms / 40))

        def step(i):
            if tok != self._toast_tok:
                return
            t = ease_out(i / steps) if i > steps * 0.4 else 0.0
            self.style.configure("Toast.TLabel",
                                 foreground=mix(self.pal["accent"], self.pal["muted"], t))
            if i < steps:
                self.root.after(40, step, i + 1)
            else:
                done()
        step(0)

    def pulse_row(self, iid):
        """Highlight a row with the accent colour and let it fade out."""
        if not self.anim_on() or not iid:
            return
        self._flash_tok += 1
        tok = self._flash_tok
        self._flash_id = iid
        if self.tree.exists(iid):
            tags = set(self.tree.item(iid, "tags") or ())
            tags.add("flash")
            self.tree.item(iid, tags=tuple(tags))
        steps = max(1, int(900 * self.speed() / 30))

        def step(i):
            if tok != self._flash_tok:
                return
            sf = self.pal["surface"]
            start = mix(self.pal["accent"], sf, 0.6)
            self.tree.tag_configure("flash", background=mix(start, sf, ease_out(i / steps)))
            if i < steps:
                self.root.after(30, step, i + 1)
            else:
                self._flash_id = None
                self.tree.tag_configure("flash", background=sf)
        step(0)

    def demo_animation(self):
        kids = self.tree.get_children()
        if kids:
            self.pulse_row(kids[0])
        self.flash("\u2728 This is how animations look")
        if self.anim_on():
            self._fade(1.0, 0.55, 160,
                       lambda: self._fade(0.55, 1.0, 220))

    def _rainbow_loop(self):
        s = self.store.settings
        active = (s.get("rainbow") and self.anim_on() and not self.palette().get("forced_accent")
                  and self.root.state() == "normal")
        if active:
            self._hue = (self._hue + 0.006 / self.speed()) % 1.0
            v = 0.95 if self.pal.get("dark") else 0.82
            r, g, b = colorsys.hsv_to_rgb(self._hue, 0.72, v)
            self.apply_accent(rgb_hex((r * 255, g * 255, b * 255)))
            delay = 70
        else:
            delay = 400
        self.root.after(delay, self._rainbow_loop)

    # ================================================================ actions
    def copy_selected(self, event=None):
        iid = self.selected_id()
        it = self.store.get(iid) if iid else None
        if not it:
            return "break"
        if self.clip.write(it["text"]):
            self.last_seen = it["text"]
            self.store.add(it["text"])
            self.mark_dirty()
            self.refresh()
            self.pulse_row(iid)
            self.flash(f"Copied {len(it['text']):,} characters")
            if self.store.settings.get("hide_after_copy", True):
                fade_ms = int(160 * self.speed()) if self.anim_on() else 0
                self.root.after(120 if self.anim_on() else 0, self.hide_window)
                if self.store.settings.get("auto_paste"):
                    self.root.after(fade_ms + 400, self._auto_paste)
        else:
            self.flash("Could not write to the clipboard")
        return "break"

    def _auto_paste(self):
        try:
            from pynput.keyboard import Controller, Key
            kb = Controller()
            with kb.pressed(Key.ctrl):
                kb.press("v")
                kb.release("v")
        except Exception:
            pass

    def pin_selected(self, event=None):
        iid = self.selected_id()
        if iid:
            self.store.toggle_pin(iid)
            self.mark_dirty()
            self.refresh()
            self.pulse_row(iid)
        return "break"

    def delete_selected(self, event=None):
        iid = self.selected_id()
        if not iid:
            return "break"
        kids = list(self.tree.get_children())
        idx = kids.index(iid) if iid in kids else 0
        self.store.remove(iid)
        self.mark_dirty()
        self.tree.selection_remove(iid)
        self.refresh()
        kids = self.tree.get_children()
        if kids:
            nxt = kids[min(idx, len(kids) - 1)]
            self.tree.selection_set(nxt)
            self.tree.focus(nxt)
        return "break"

    def clear_unpinned(self):
        if messagebox.askyesno(APP_NAME, "Delete all unpinned clips?", parent=self.root):
            self.store.clear_unpinned()
            self.mark_dirty()
            self.refresh()

    def set_paused(self, value):
        self.paused = bool(value)
        self.pause_var.set(self.paused)
        if self.icon:
            try:
                self.icon.update_menu()
            except Exception:
                pass
        self.update_status()

    def open_settings(self):
        if self.dialog_alive():
            self.settings_dialog.top.deiconify()
            self.settings_dialog.top.lift()
            self.settings_dialog.top.focus_force()
        else:
            self.settings_dialog = SettingsDialog(self)
        return "break"

    def dialog_alive(self):
        d = self.settings_dialog
        try:
            return bool(d and d.top.winfo_exists())
        except Exception:
            return False

    # ================================================================ window
    def show_window(self, near_pointer=True):
        r = self.root
        animate = self.anim_on() and r.state() != "normal"
        if animate:
            self._set_alpha(0.0)
        r.deiconify()
        if near_pointer:
            try:
                r.update_idletasks()
                w, h = max(r.winfo_width(), 400), max(r.winfo_height(), 300)
                px, py = r.winfo_pointerxy()
                sw, sh = r.winfo_screenwidth(), r.winfo_screenheight()
                x = min(max(px - w // 2, 0), max(sw - w, 0))
                y = min(max(py - 40, 0), max(sh - h - 40, 0))
                r.geometry(f"+{x}+{y}")
            except Exception:
                pass
        r.lift()
        try:
            r.attributes("-topmost", True)
            r.after(200, lambda: r.attributes("-topmost", False))
        except tk.TclError:
            pass
        r.focus_force()
        self.search.focus_set()
        self.search.select_range(0, "end")
        if animate:
            self._fade(0.0, 1.0, 200)
        else:
            self._fade_tok += 1
            self._set_alpha(1.0)

    def hide_window(self, animate=True):
        def finish():
            if self.can_withdraw:
                self.root.withdraw()
            else:
                self.root.iconify()
            self._set_alpha(1.0)

        if animate and self.anim_on() and self.root.state() == "normal":
            self._fade(self._get_alpha(), 0.0, 160, finish)
        else:
            self._fade_tok += 1
            finish()

    def toggle_window(self):
        try:
            visible = self.root.state() == "normal" and self.root.focus_get() is not None
        except Exception:
            visible = False
        self.hide_window() if visible else self.show_window()

    # ================================================================ background loops
    def _poll(self):
        if not self.paused:
            try:
                if not self.clip.is_secret():
                    text = self.clip.read()
                    if text is not None and text != self.last_seen:
                        self.last_seen = text
                        if self.store.add(text):
                            self.mark_dirty()
                            new_id = self.store.items[0]["id"]
                            if self.anim_on():
                                self._flash_id = new_id
                            self.refresh()
                            self.pulse_row(new_id)
            except Exception:
                pass
        self.root.after(max(150, int(self.store.settings.get("poll_ms", 500))), self._poll)

    def _process_queue(self):
        try:
            while True:
                msg = self.q.get_nowait()
                if msg == "show":
                    self.show_window()
                elif msg == "toggle":
                    self.toggle_window()
                elif msg == "pause":
                    self.set_paused(not self.paused)
                elif msg == "quit":
                    self.quit()
                    return
        except queue.Empty:
            pass
        self.root.after(100, self._process_queue)

    def mark_dirty(self):
        self.dirty = True

    def _autosave(self):
        if self.dirty:
            try:
                self.store.save()
                self.dirty = False
            except Exception:
                pass
        self.root.after(2000, self._autosave)

    def _tick(self):
        self.refresh()  # updates "5m ago" labels
        self.root.after(60000, self._tick)

    # ================================================================ optional integrations
    def _setup_hotkey(self):
        hk = self.store.settings.get("hotkey")
        if not hk:
            return False
        try:
            from pynput import keyboard
            self._hk = keyboard.GlobalHotKeys({hk: lambda: self.q.put("toggle")})
            self._hk.daemon = True
            self._hk.start()
            return True
        except Exception:
            return False

    def _setup_tray(self):
        try:
            import pystray
        except Exception:
            return False
        backend = pystray.Icon.__module__.rsplit(".", 1)[-1]
        # On Ubuntu only the AppIndicator backend shows a visible icon on GNOME
        if backend not in ("_win32", "_appindicator"):
            return False
        try:
            menu = pystray.Menu(
                pystray.MenuItem("Show", lambda i, it: self.q.put("show"), default=True),
                pystray.MenuItem("Pause monitoring", lambda i, it: self.q.put("pause"),
                                 checked=lambda it: self.paused),
                pystray.Menu.SEPARATOR,
                pystray.MenuItem("Quit", lambda i, it: self.q.put("quit")),
            )
            color = self.palette().get("forced_accent") or self.store.settings.get("accent")
            self.icon = pystray.Icon("clipkeeper", make_pil_icon(color), APP_NAME, menu)
            threading.Thread(target=self.icon.run, daemon=True).start()
            return True
        except Exception:
            self.icon = None
            return False

    def quit(self):
        try:
            self.store.save()
        except Exception:
            pass
        for stopper in (lambda: self.icon and self.icon.stop(),
                        lambda: self._hk and self._hk.stop(),
                        lambda: self.srv and self.srv.close()):
            try:
                stopper()
            except Exception:
                pass
        self.root.destroy()


# ----------------------------------------------------------------------------- settings window
class SettingsDialog:
    SW_R = 12      # swatch radius
    SW_GAP = 34    # swatch spacing
    SW_COLS = 6

    def __init__(self, app):
        self.app = app
        self.s = app.store.settings
        top = self.top = tk.Toplevel(app.root)
        top.title(f"{APP_NAME} Settings")
        top.transient(app.root)
        top.resizable(False, False)
        top.protocol("WM_DELETE_WINDOW", self.close)
        top.bind("<Escape>", lambda e: self.close())

        self.sample_bar = tk.Frame(top, height=4, bd=0, highlightthickness=0)
        self.sample_bar.pack(fill="x")
        nb = ttk.Notebook(top)
        nb.pack(fill="both", expand=True, padx=10, pady=(10, 0))
        self._build_appearance(nb)
        self._build_fonts(nb)
        self._build_animation(nb)
        self._build_general(nb)

        bottom = ttk.Frame(top, padding=10)
        bottom.pack(fill="x")
        ttk.Button(bottom, text="Reset all settings", command=self.reset_all).pack(side="left")
        ttk.Label(bottom, text=f"v{APP_VERSION}", style="Muted.TLabel").pack(side="left", padx=10)
        ttk.Button(bottom, text="Done", style="Accent.TButton", command=self.close).pack(side="right")

        self.recolor()
        self.app.apply_accent(self.app.pal["accent"])
        top.update_idletasks()
        try:
            x = app.root.winfo_rootx() + 40
            y = app.root.winfo_rooty() + 40
            top.geometry(f"+{x}+{y}")
        except Exception:
            pass
        top.focus_force()

    # ---------------- tabs
    def _build_appearance(self, nb):
        f = ttk.Frame(nb, padding=16)
        nb.add(f, text="Appearance")
        ttk.Label(f, text="Screen mode", style="Section.TLabel").grid(row=0, column=0, sticky="w")
        self.mode_var = tk.StringVar(value=self.s["theme"])
        mf = ttk.Frame(f)
        mf.grid(row=1, column=0, sticky="w", pady=(6, 14))
        for i, key in enumerate(THEME_ORDER):
            ttk.Radiobutton(mf, text=PALETTES[key]["label"], value=key, variable=self.mode_var,
                            command=self.on_mode).grid(row=i // 3, column=i % 3, sticky="w",
                                                       padx=(0, 18), pady=3)

        ttk.Label(f, text="Accent colour", style="Section.TLabel").grid(row=2, column=0, sticky="w")
        rows = (len(ACCENTS) + self.SW_COLS - 1) // self.SW_COLS
        self.canvas = tk.Canvas(f, width=self.SW_COLS * self.SW_GAP + 8,
                                height=rows * self.SW_GAP + 8, highlightthickness=0,
                                cursor="hand2", bd=0)
        self.canvas.grid(row=3, column=0, sticky="w", pady=(6, 6))

        af = ttk.Frame(f)
        af.grid(row=4, column=0, sticky="w")
        ttk.Button(af, text="Custom colour\u2026", command=self.custom_accent).pack(side="left")
        self.rainbow_var = tk.BooleanVar(value=bool(self.s.get("rainbow")))
        ttk.Checkbutton(af, text="Rainbow (animated multi-colour)", variable=self.rainbow_var,
                        command=self.on_rainbow).pack(side="left", padx=(12, 0))
        self.accent_note = ttk.Label(f, style="Muted.TLabel")
        self.accent_note.grid(row=5, column=0, sticky="w", pady=(10, 0))

    def _build_fonts(self, nb):
        f = ttk.Frame(nb, padding=16)
        nb.add(f, text="Fonts")
        fams = sorted({x for x in tkfont.families(self.app.root) if not x.startswith("@")},
                      key=str.lower)
        self.families = ["(default)"] + fams

        self.ui_fam = tk.StringVar()
        self.ui_size = tk.IntVar()
        self.pv_fam = tk.StringVar()
        self.pv_size = tk.IntVar()

        def row(r, label, fam_var, size_var):
            ttk.Label(f, text=label, style="Section.TLabel").grid(row=r, column=0, sticky="w",
                                                                  pady=(0 if r == 0 else 12, 4),
                                                                  columnspan=2)
            cb = ttk.Combobox(f, textvariable=fam_var, values=self.families, state="readonly", width=30)
            cb.grid(row=r + 1, column=0, sticky="w")
            cb.bind("<<ComboboxSelected>>", lambda e: self.on_fonts())
            sp = ttk.Spinbox(f, from_=7, to=32, width=5, textvariable=size_var, command=self.on_fonts)
            sp.grid(row=r + 1, column=1, sticky="w", padx=(8, 0))
            sp.bind("<Return>", lambda e: self.on_fonts())
            sp.bind("<FocusOut>", lambda e: self.on_fonts())
            ttk.Label(f, text="pt", style="Muted.TLabel").grid(row=r + 1, column=2, sticky="w", padx=4)

        row(0, "Interface font", self.ui_fam, self.ui_size)
        row(2, "Clip preview font", self.pv_fam, self.pv_size)

        ttk.Label(f, text="Preview", style="Section.TLabel").grid(row=4, column=0, sticky="w", pady=(14, 4))
        ttk.Label(f, text="Aa Bb Cc \u2014 The quick brown fox jumps 0123",
                  font=self.app.f_ui).grid(row=5, column=0, columnspan=3, sticky="w")
        self.pv_sample = tk.Label(f, text="const clip = copy('Hello, ClipKeeper!');",
                                  font=self.app.f_prev, anchor="w", padx=8, pady=6,
                                  highlightthickness=1, bd=0)
        self.pv_sample.grid(row=6, column=0, columnspan=3, sticky="we", pady=(6, 0))

        bf = ttk.Frame(f)
        bf.grid(row=7, column=0, columnspan=3, sticky="w", pady=(12, 0))
        ttk.Button(bf, text="Reset fonts", command=self.reset_fonts).pack(side="left")
        ttk.Label(bf, text="Tip: Ctrl + / Ctrl \u2212 / Ctrl 0 in the main window",
                  style="Muted.TLabel").pack(side="left", padx=10)
        self.sync()

    def _build_animation(self, nb):
        f = ttk.Frame(nb, padding=16)
        nb.add(f, text="Animation")
        self.anim_var = tk.BooleanVar(value=bool(self.s.get("animations", True)))
        ttk.Checkbutton(f, text="Enable animations", variable=self.anim_var,
                        command=self.on_anim).grid(row=0, column=0, sticky="w")
        ttk.Label(f, text="Window fade in/out, new-clip highlight pulse, fading status messages\n"
                          "and the Rainbow accent.", style="Muted.TLabel", justify="left"
                  ).grid(row=1, column=0, sticky="w", pady=(4, 14))
        ttk.Label(f, text="Speed", style="Section.TLabel").grid(row=2, column=0, sticky="w")
        self.speed_var = tk.StringVar(value=self.s.get("anim_speed", "normal"))
        sf = ttk.Frame(f)
        sf.grid(row=3, column=0, sticky="w", pady=(6, 14))
        for key, label in (("slow", "Slow"), ("normal", "Normal"), ("fast", "Fast")):
            ttk.Radiobutton(sf, text=label, value=key, variable=self.speed_var,
                            command=self.on_anim).pack(side="left", padx=(0, 18))
        ttk.Button(f, text="Preview animation", command=self.app.demo_animation
                   ).grid(row=4, column=0, sticky="w")

    def _build_general(self, nb):
        f = ttk.Frame(nb, padding=16)
        nb.add(f, text="General")
        self.gen_vars = {}
        for r, (key, label) in enumerate((
                ("hide_after_copy", "Hide window after copying a clip"),
                ("auto_paste", "Auto-paste into the previous app (needs pynput)"),
                ("save_history", "Save history to disk"))):
            v = tk.BooleanVar(value=bool(self.s.get(key)))
            self.gen_vars[key] = v
            ttk.Checkbutton(f, text=label, variable=v,
                            command=lambda k=key: self.on_bool(k)).grid(row=r, column=0, columnspan=2,
                                                                         sticky="w", pady=2)
        ttk.Label(f, text="Keep unpinned clips").grid(row=3, column=0, sticky="w", pady=(12, 0))
        self.max_var = tk.IntVar(value=int(self.s.get("max_items", 300)))
        sp = ttk.Spinbox(f, from_=20, to=5000, increment=10, width=7, textvariable=self.max_var,
                         command=self.on_max)
        sp.grid(row=3, column=1, sticky="w", padx=(8, 0), pady=(12, 0))
        sp.bind("<Return>", lambda e: self.on_max())
        sp.bind("<FocusOut>", lambda e: self.on_max())

        ttk.Label(f, text="Global hotkey").grid(row=4, column=0, sticky="w", pady=(10, 0))
        self.hk_var = tk.StringVar(value=self.s.get("hotkey", ""))
        e = ttk.Entry(f, textvariable=self.hk_var, width=18)
        e.grid(row=4, column=1, sticky="w", padx=(8, 0), pady=(10, 0))
        e.bind("<FocusOut>", lambda ev: self.on_hotkey())
        e.bind("<Return>", lambda ev: self.on_hotkey())
        ttk.Label(f, text="pynput format, e.g. <ctrl>+<alt>+v  \u00b7  applies after restart",
                  style="Muted.TLabel").grid(row=5, column=0, columnspan=2, sticky="w", pady=(2, 12))
        ttk.Label(f, text=f"Data file: {self.app.store.path}", style="Muted.TLabel",
                  wraplength=420, justify="left").grid(row=6, column=0, columnspan=2, sticky="w")

    # ---------------- sync / colours
    def recolor(self):
        p = self.app.pal
        self.top.configure(bg=p["bg"])
        set_win_titlebar(self.top, p["dark"])
        self.mode_var.set(self.s["theme"])
        self.rainbow_var.set(bool(self.s.get("rainbow")))
        self.pv_sample.configure(bg=p["surface"], fg=p["fg"], highlightbackground=p["border"])
        self.sample_bar.configure(bg=p.get("accent", p["fg"]))
        self.draw_swatches()

    def sync(self):
        self.ui_fam.set(self.s.get("font_family") or "(default)")
        self.ui_size.set(int(self.s.get("font_size", 10)))
        self.pv_fam.set(self.s.get("preview_font_family") or "(default)")
        self.pv_size.set(int(self.s.get("preview_font_size", 10)))

    def draw_swatches(self):
        p = self.app.pal
        c = self.canvas
        c.delete("all")
        c.configure(bg=p["bg"])
        forced = PALETTES[self.s["theme"]].get("forced_accent")
        cur = (self.s.get("accent") or "").lower()
        for i, (name, col) in enumerate(ACCENTS):
            x = 4 + self.SW_GAP // 2 + (i % self.SW_COLS) * self.SW_GAP
            y = 4 + self.SW_GAP // 2 + (i // self.SW_COLS) * self.SW_GAP
            r = self.SW_R
            fill = mix(col, p["bg"], 0.7) if forced else col
            if col.lower() == cur and not self.s.get("rainbow") and not forced:
                c.create_oval(x - r - 4, y - r - 4, x + r + 4, y + r + 4, outline=p["fg"], width=2)
            item = c.create_oval(x - r, y - r, x + r, y + r, fill=fill,
                                 outline=mix(col, p["fg"], 0.25))
            c.tag_bind(item, "<Button-1>", lambda e, col=col: self.pick_accent(col))
        if forced:
            note = "Accent colour is fixed in high-contrast black & white modes."
        elif self.s.get("rainbow"):
            note = "Rainbow is on \u2014 the accent cycles through all colours."
            if not self.s.get("animations", True):
                note += " (Enable animations to see it.)"
        else:
            name = next((n for n, c2 in ACCENTS if c2.lower() == cur), "Custom")
            note = f"Current accent: {name} ({self.s.get('accent')})"
        self.accent_note.configure(text=note)

    # ---------------- handlers
    def _changed(self):
        self.app.mark_dirty()

    def on_mode(self):
        self.s["theme"] = self.mode_var.get()
        self._changed()
        self.app.apply_theme()

    def pick_accent(self, col):
        self.s["accent"] = col
        self.s["rainbow"] = False
        self._changed()
        self.app.apply_theme()

    def custom_accent(self):
        res = colorchooser.askcolor(color=self.s.get("accent"), parent=self.top,
                                    title="Choose accent colour")
        if res and res[1]:
            self.pick_accent(valid_hex(res[1], DEFAULTS["accent"]))

    def on_rainbow(self):
        self.s["rainbow"] = bool(self.rainbow_var.get())
        self._changed()
        self.app.apply_theme()

    def on_fonts(self):
        def num(var, fallback):
            try:
                return max(7, min(32, int(var.get())))
            except Exception:
                return fallback
        uf, pf = self.ui_fam.get(), self.pv_fam.get()
        self.s["font_family"] = "" if uf == "(default)" else uf
        self.s["preview_font_family"] = "" if pf == "(default)" else pf
        self.s["font_size"] = num(self.ui_size, self.s.get("font_size", 10))
        self.s["preview_font_size"] = num(self.pv_size, self.s.get("preview_font_size", 10))
        self._changed()
        self.app.apply_fonts()

    def reset_fonts(self):
        for k in ("font_family", "font_size", "preview_font_family", "preview_font_size"):
            self.s[k] = DEFAULTS[k]
        self._changed()
        self.app.apply_fonts()

    def on_anim(self):
        self.s["animations"] = bool(self.anim_var.get())
        self.s["anim_speed"] = self.speed_var.get()
        self._changed()
        self.app.apply_theme()
        if self.s["animations"]:
            self.app.demo_animation()

    def on_bool(self, key):
        self.s[key] = bool(self.gen_vars[key].get())
        self._changed()

    def on_max(self):
        try:
            self.s["max_items"] = max(20, min(5000, int(self.max_var.get())))
        except Exception:
            self.max_var.set(self.s.get("max_items", 300))
            return
        self.app.store.trim()
        self._changed()
        self.app.refresh()

    def on_hotkey(self):
        val = self.hk_var.get().strip()
        if val != self.s.get("hotkey"):
            self.s["hotkey"] = val
            self._changed()
            self.app.flash("Hotkey saved \u2014 restart ClipKeeper to apply")

    def reset_all(self):
        if not messagebox.askyesno(APP_NAME, "Reset all settings to defaults?\n"
                                             "Your clip history is kept.", parent=self.top):
            return
        self.s.clear()
        self.s.update(DEFAULTS)
        self._changed()
        self.top.destroy()
        self.app.apply_fonts()
        self.app.apply_theme()
        self.app.settings_dialog = SettingsDialog(self.app)

    def close(self):
        try:
            self.app.store.save()
            self.app.dirty = False
        except Exception:
            pass
        self.top.destroy()


# ----------------------------------------------------------------------------- autostart
def _launcher():
    exe = sys.executable
    if IS_WIN:
        pw = os.path.join(os.path.dirname(exe), "pythonw.exe")
        if os.path.exists(pw):
            exe = pw
    return exe, os.path.abspath(__file__)


def _linux_paths():
    return (os.path.expanduser("~/.config/autostart/clipkeeper.desktop"),
            os.path.expanduser("~/.local/share/applications/clipkeeper.desktop"))


def _win_startup_file():
    return os.path.join(os.environ.get("APPDATA", ""),
                        r"Microsoft\Windows\Start Menu\Programs\Startup", "ClipKeeper.vbs")


def install_autostart():
    exe, script = _launcher()
    if IS_WIN:
        path = _win_startup_file()
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-16") as f:
            f.write('Set sh = CreateObject("WScript.Shell")\r\n')
            f.write(f'sh.Run """{exe}"" ""{script}"" --hidden", 0, False\r\n')
        print(f"Autostart installed: {path}")
        return
    auto, app = _linux_paths()
    entry = ("[Desktop Entry]\nType=Application\nName=ClipKeeper\n"
             "Comment=Clipboard history manager\nIcon=edit-paste\nTerminal=false\n"
             "Categories=Utility;\n")
    for path, extra in ((auto, " --hidden\nX-GNOME-Autostart-enabled=true"), (app, "")):
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            f.write(entry + f'Exec="{exe}" "{script}"{extra}\n')
        os.chmod(path, 0o755)
    print(f"Autostart installed: {auto}\nApp menu entry: {app}")


def remove_autostart():
    paths = [_win_startup_file()] if IS_WIN else list(_linux_paths())
    for p in paths:
        try:
            os.remove(p)
            print(f"Removed {p}")
        except FileNotFoundError:
            pass


# ----------------------------------------------------------------------------- main
def main():
    ap = argparse.ArgumentParser(description="ClipKeeper clipboard history manager")
    ap.add_argument("--hidden", action="store_true", help="start in background")
    ap.add_argument("--install-autostart", action="store_true", help="launch at login")
    ap.add_argument("--remove-autostart", action="store_true", help="stop launching at login")
    args = ap.parse_args()

    if args.install_autostart:
        install_autostart()
        return
    if args.remove_autostart:
        remove_autostart()
        return

    if args.hidden:
        # Already running? Stay quiet.
        try:
            with socket.create_connection(("127.0.0.1", IPC_PORT), timeout=0.4):
                return
        except OSError:
            pass
    elif send_to_running():
        return

    if IS_WIN:
        try:
            import ctypes
            ctypes.windll.shcore.SetProcessDpiAwareness(1)
        except Exception:
            pass

    root = tk.Tk()
    store = Store(os.path.join(data_dir(), "history.json"))
    App(root, store, start_hidden=args.hidden)
    root.mainloop()


if __name__ == "__main__":
    main()
