发一个快速阅读的小工具

"""
PDF 自动翻页工具 v4
跨平台 (macOS / Windows 11)
──────────────────────────────────────────────────────────────────────────────
热键后端优先级:pynput(Mac/Win 均可用,无需管理员权限)→ keyboard(仅 Win)
功能:
  - tkinter 浮动 GUI,置顶开关可切换
  - Ctrl+Shift+↑ 加速 / Ctrl+Shift+↓ 减速 / Ctrl+Shift+P 暂停/继续
    (避免与 PDF 阅读器 F1/F2/F3 冲突)
  - GUI 内 [-] [+] 按钮 + 暂停按钮,不依赖热键亦可操作
  - PageDown / Space 手动翻页:始终重置计时(不会立即再自动翻页)
  - STAY_GUARD = 2.0 s:鼠标触发防双翻保护窗口
  - 分别统计自动翻页 / 手动翻页次数
  - 独立统计阅读时间(不含暂停)和累计暂停时间
  - 关闭时追加写入 scroll_history.json
  - 鼠标左边缘触发(可选,适合轨迹球)
"""

import sys
import os
import json
import tkinter as tk
import tkinter.simpledialog as sd
from tkinter import ttk
import pyautogui
import time
import threading
from datetime import datetime, timedelta

# ── 平台 ──────────────────────────────────────────────────────────────────────
PLATFORM = sys.platform   # 'darwin' | 'win32' | 'linux'

HISTORY_FILE = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "scroll_history.json"
)

# 字体(Segoe UI 仅 Windows;macOS 用 Helvetica)
_FONT_BOLD  = ("Helvetica", 11, "bold") if PLATFORM == "darwin" else ("Segoe UI", 11, "bold")
_FONT_SMALL = ("Helvetica", 8)          if PLATFORM == "darwin" else ("Segoe UI", 8)

# ── 热键后端 ──────────────────────────────────────────────────────────────────
try:
    from pynput import keyboard as _pynput_kb
    _BACKEND = "pynput"
except ImportError:
    _pynput_kb = None
    try:
        import keyboard as _kb_module
        _BACKEND = "keyboard"
    except ImportError:
        _kb_module = None
        _BACKEND = "none"


class AutoScroller:
    SPEED_MIN  = 1.0    # 最短翻页间隔(秒)
    SPEED_MAX  = 120.0  # 最长翻页间隔(秒)
    SPEED_STEP = 1.0    # 每次调速步长(秒)
    STAY_GUARD = 2.0    # 鼠标触发防双翻保护窗口(秒);键盘无此限制

    def __init__(self, interval: float):
        self.interval      = interval
        self._timer        = interval

        # 翻页计数(自动 / 手动 分开统计)
        self._auto_count   = 0
        self._manual_count = 0

        # 时间统计
        self._session_start    = time.time()
        self._total_pause_secs = 0.0
        self._pause_start      = None   # 非 None 表示当前处于暂停中

        self._running   = True
        self._lock      = threading.Lock()
        self._pause_evt = threading.Event()
        self._pause_evt.set()   # 初始:运行中

        self._build_gui()
        self._register_hotkeys()

        threading.Thread(target=self._scroll_loop, daemon=True).start()
        threading.Thread(target=self._mouse_loop,  daemon=True).start()

        self._refresh_gui()   # 启动 GUI 周期刷新

    # ── GUI ───────────────────────────────────────────────────────────────────

    def _build_gui(self):
        self.root = tk.Tk()
        self.root.title("PDF 自动翻页 v4")
        self.root.resizable(False, False)

        # BooleanVar 必须在 Tk() 之后创建
        self._mouse_enabled = tk.BooleanVar(value=False)
        self._topmost_var   = tk.BooleanVar(value=True)
        self._topmost_var.trace_add("write", self._toggle_topmost)
        self.root.attributes('-topmost', True)

        frm = ttk.Frame(self.root, padding=12)
        frm.pack(fill=tk.BOTH, expand=True)

        # 状态行
        self.sv_status = tk.StringVar(value="▶ 运行中")
        self._lbl_status = ttk.Label(
            frm, textvariable=self.sv_status,
            font=_FONT_BOLD, foreground="#1a7a1a"
        )
        self._lbl_status.grid(row=0, column=0, columnspan=3, pady=(0, 8))

        # 数据行(3列:标签 / 值 / 占位)
        rows = [
            ("自动翻页", "sv_auto",      "0 页"),
            ("手动翻页", "sv_manual",    "0 页"),
            ("阅读时间", "sv_reading",   "00:00:00"),
            ("暂停时间", "sv_pause_t",   "00:00:00"),
            ("翻页间隔", "sv_interval",  f"{self.interval:.0f} 秒"),
            ("下次翻页", "sv_countdown", f"{self.interval:.1f} 秒"),
        ]
        for i, (lbl, attr, init) in enumerate(rows, start=1):
            ttk.Label(frm, text=lbl + ":", anchor="e").grid(
                row=i, column=0, sticky="e", pady=2
            )
            sv = tk.StringVar(value=init)
            setattr(self, attr, sv)
            ttk.Label(frm, textvariable=sv, anchor="w").grid(
                row=i, column=1, columnspan=2, sticky="w", padx=(6, 0), pady=2
            )

        n = len(rows)

        # ── 速度控制按钮行 ──────────────────────────────────────────────────
        ttk.Separator(frm, orient="horizontal").grid(
            row=n + 1, column=0, columnspan=3, sticky="ew", pady=(8, 4)
        )
        ctrl_frm = ttk.Frame(frm)
        ctrl_frm.grid(row=n + 2, column=0, columnspan=3, pady=(0, 4))

        ttk.Button(ctrl_frm, text="  −  ", width=4,
                   command=self._slower).pack(side=tk.LEFT, padx=2)
        ttk.Label(ctrl_frm, text="速度").pack(side=tk.LEFT, padx=4)
        ttk.Button(ctrl_frm, text="  +  ", width=4,
                   command=self._faster).pack(side=tk.LEFT, padx=2)

        self._btn_pause = ttk.Button(
            ctrl_frm, text="⏸ 暂停", width=8, command=self._toggle_pause
        )
        self._btn_pause.pack(side=tk.LEFT, padx=(10, 2))

        # ── 选项 ────────────────────────────────────────────────────────────
        ttk.Checkbutton(
            frm, text="始终置顶",
            variable=self._topmost_var
        ).grid(row=n + 3, column=0, columnspan=3, sticky="w")

        ttk.Checkbutton(
            frm, text="鼠标左边缘触发翻页",
            variable=self._mouse_enabled
        ).grid(row=n + 4, column=0, columnspan=3, sticky="w")

        # ── 热键提示 ─────────────────────────────────────────────────────────
        ttk.Label(
            frm,
            text="Ctrl+Shift+↑ 加速 · Ctrl+Shift+↓ 减速 · Ctrl+Shift+P 暂停",
            foreground="gray", font=_FONT_SMALL
        ).grid(row=n + 5, column=0, columnspan=3, pady=(4, 0))

        backend_tip = {"pynput": "热键:pynput", "keyboard": "热键:keyboard", "none": "⚠ 未找到热键库"}
        ttk.Label(
            frm, text=backend_tip.get(_BACKEND, ""),
            foreground="gray", font=_FONT_SMALL
        ).grid(row=n + 6, column=0, columnspan=3)

    # ── 热键注册(跨平台)────────────────────────────────────────────────────

    def _register_hotkeys(self):
        """
        热键方案(避免与 PDF 阅读器冲突)
          Ctrl+Shift+↑  → 加速(减小间隔)
          Ctrl+Shift+↓  → 减速(增大间隔)
          Ctrl+Shift+P  → 暂停/继续
          PageDown / Space → 手动翻页计时重置(pass-through,阅读器正常接收)
        """
        if _BACKEND == "pynput":
            _mods = {'ctrl': False, 'shift': False}
            K = _pynput_kb.Key

            def on_press(key):
                try:
                    if key in (K.ctrl_l, K.ctrl_r, K.ctrl):
                        _mods['ctrl'] = True
                    elif key in (K.shift, K.shift_l, K.shift_r):
                        _mods['shift'] = True
                    elif _mods['ctrl'] and _mods['shift']:
                        if key == K.up:
                            self._faster()
                        elif key == K.down:
                            self._slower()
                        elif hasattr(key, 'char') and key.char in ('p', 'P'):
                            self._toggle_pause()
                    elif key == K.page_down:
                        self._on_manual()
                    elif key == K.space:
                        self._on_manual()
                except Exception:
                    pass

            def on_release(key):
                try:
                    if key in (K.ctrl_l, K.ctrl_r, K.ctrl):
                        _mods['ctrl'] = False
                    elif key in (K.shift, K.shift_l, K.shift_r):
                        _mods['shift'] = False
                except Exception:
                    pass

            self._kb_listener = _pynput_kb.Listener(
                on_press=on_press, on_release=on_release, daemon=True
            )
            self._kb_listener.start()

        elif _BACKEND == "keyboard":
            # suppress=True:Ctrl+Shift 组合键被本程序消费,不传给 PDF 阅读器
            # suppress=False:PageDown/Space 仍透传,让阅读器正常翻页
            _kb_module.add_hotkey('ctrl+shift+up',   self._faster,       suppress=True)
            _kb_module.add_hotkey('ctrl+shift+down',  self._slower,       suppress=True)
            _kb_module.add_hotkey('ctrl+shift+p',     self._toggle_pause, suppress=True)
            _kb_module.add_hotkey('pagedown',         self._on_manual,    suppress=False)
            _kb_module.add_hotkey('space',            self._on_manual,    suppress=False)

    # ── 后台翻页线程 ──────────────────────────────────────────────────────────

    def _scroll_loop(self):
        while self._running:
            self._pause_evt.wait()   # 暂停时在此阻塞,不消耗 CPU
            time.sleep(0.1)
            do_turn = False
            with self._lock:
                self._timer -= 0.1
                if self._timer <= 0:
                    self._auto_count += 1
                    self._timer = self.interval
                    do_turn = True
            if do_turn:
                pyautogui.press('pagedown')

    # ── 鼠标边缘触发线程 ─────────────────────────────────────────────────────

    def _mouse_loop(self):
        """鼠标移到屏幕最左侧(x < 50)时触发翻页,离开后自动移回。"""
        while self._running:
            try:
                if self._mouse_enabled.get() and self._pause_evt.is_set():
                    pos = pyautogui.position()
                    if pos.x < 50:
                        do_turn = False
                        with self._lock:
                            if self._timer > self.STAY_GUARD:
                                self._manual_count += 1
                                self._timer = self.interval
                                do_turn = True
                        if do_turn:
                            pyautogui.press('pagedown')
                            time.sleep(0.1)
                            pos2 = pyautogui.position()
                            if pos2.x <= 130:
                                pyautogui.moveTo(130, pos2.y)
            except Exception:
                pass
            time.sleep(0.1)

    # ── 热键回调 ──────────────────────────────────────────────────────────────

    def _on_manual(self):
        """
        键盘手动翻页(PageDown / Space):
        始终重置计时器到完整间隔,确保手动翻后不会立即自动翻。
        键盘本身已将按键传给 PDF 阅读器,此处不再额外 press()。
        """
        with self._lock:
            self._manual_count += 1
            self._timer = self.interval

    def _faster(self):
        with self._lock:
            self.interval = max(self.SPEED_MIN, self.interval - self.SPEED_STEP)
            self._timer   = min(self._timer, self.interval)

    def _slower(self):
        with self._lock:
            self.interval = min(self.SPEED_MAX, self.interval + self.SPEED_STEP)

    def _toggle_topmost(self, *_):
        topmost = self._topmost_var.get()
        self.root.attributes('-topmost', topmost)

    def _toggle_pause(self):
        if self._pause_evt.is_set():
            self._pause_evt.clear()   # → 暂停
            with self._lock:
                self._pause_start = time.time()
        else:
            self._pause_evt.set()     # → 继续
            with self._lock:
                if self._pause_start is not None:
                    self._total_pause_secs += time.time() - self._pause_start
                    self._pause_start = None

    # ── GUI 刷新(每 100 ms)─────────────────────────────────────────────────

    def _refresh_gui(self):
        now = time.time()

        with self._lock:
            auto_c    = self._auto_count
            manual_c  = self._manual_count
            interval  = self.interval
            countdown = max(0.0, self._timer)
            pause_acc = self._total_pause_secs
            p_start   = self._pause_start

        # 当前暂停段(如正在暂停)
        current_pause = (now - p_start) if p_start is not None else 0.0
        total_pause   = pause_acc + current_pause
        reading_time  = max(0.0, (now - self._session_start) - total_pause)

        paused = not self._pause_evt.is_set()

        self.sv_auto.set(f"{auto_c} 页")
        self.sv_manual.set(f"{manual_c} 页")
        self.sv_reading.set(str(timedelta(seconds=int(reading_time))))
        self.sv_pause_t.set(str(timedelta(seconds=int(total_pause))))
        self.sv_interval.set(f"{interval:.0f} 秒")
        self.sv_countdown.set("已暂停" if paused else f"{countdown:.1f} 秒")

        if paused:
            self.sv_status.set("⏸ 已暂停")
            self._lbl_status.configure(foreground="#b05a00")
            self._btn_pause.configure(text="▶ 继续")
        else:
            self.sv_status.set("▶ 运行中")
            self._lbl_status.configure(foreground="#1a7a1a")
            self._btn_pause.configure(text="⏸ 暂停")

        self.root.after(100, self._refresh_gui)

    # ── 历史记录 ──────────────────────────────────────────────────────────────

    def _save_history(self):
        now = time.time()

        with self._lock:
            pause_acc  = self._total_pause_secs
            p_start    = self._pause_start
            auto_c     = self._auto_count
            manual_c   = self._manual_count
            interval   = self.interval

        current_pause = (now - p_start) if p_start is not None else 0.0
        total_pause   = pause_acc + current_pause
        reading_time  = max(0.0, (now - self._session_start) - total_pause)

        record = {
            "date":            datetime.now().strftime("%Y-%m-%d"),
            "start":           datetime.fromtimestamp(self._session_start).strftime("%H:%M:%S"),
            "end":             datetime.now().strftime("%H:%M:%S"),
            "reading_seconds": int(reading_time),
            "pause_seconds":   int(total_pause),
            "auto_turns":      auto_c,
            "manual_turns":    manual_c,
            "interval_final":  round(interval, 1),
            "platform":        PLATFORM,
        }

        records = []
        if os.path.exists(HISTORY_FILE):
            try:
                with open(HISTORY_FILE, "r", encoding="utf-8") as f:
                    records = json.load(f)
            except Exception:
                records = []

        records.append(record)

        try:
            with open(HISTORY_FILE, "w", encoding="utf-8") as f:
                json.dump(records, f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    # ── 生命周期 ──────────────────────────────────────────────────────────────

    def run(self):
        self.root.protocol("WM_DELETE_WINDOW", self._shutdown)
        self.root.mainloop()

    def _shutdown(self):
        self._running = False
        self._pause_evt.set()   # 解除阻塞让翻页线程退出
        self._save_history()

        if _BACKEND == "pynput":
            try:
                self._kb_listener.stop()
            except Exception:
                pass
        elif _BACKEND == "keyboard":
            try:
                _kb_module.unhook_all()
            except Exception:
                pass

        self.root.destroy()


# ── 入口 ──────────────────────────────────────────────────────────────────────

def main():
    tmp = tk.Tk()
    tmp.withdraw()
    interval = sd.askfloat(
        "PDF 自动翻页",
        "请输入翻页间隔(秒):",
        minvalue=1.0, maxvalue=600.0, initialvalue=5.0,
        parent=tmp,
    )
    tmp.destroy()
    if interval is None:
        return   # 用户取消
    AutoScroller(interval).run()


if __name__ == "__main__":
    main()