番茄計時器"""
番茄計時器 — CustomTkinter
支援:
• 同時設定多個計時器
• 兩種模式:「幾分鐘後」/ 「指定時刻」
• 自訂提醒文字
• 時間到彈出通知視窗 + 系統通知(可選)
• 開始 / 暫停 / 重置
"""
import customtkinter as ctk
import tkinter as tk
from tkinter import messagebox
from datetime import datetime, timedelta
import time, threading
# ── 全域主題 ──────────────────────────────────────────────
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
PALETTE = {
"bg": "#0d1117",
"panel": "#161b22",
"border": "#30363d",
"accent": "#58a6ff",
"green": "#3fb950",
"orange": "#f0883e",
"red": "#f85149",
"text": "#e6edf3",
"subtext": "#8b949e",
}
# ── 單一計時器卡片 ────────────────────────────────────────
class TimerCard(ctk.CTkFrame):
CARD_ID = 0
def __init__(self, master, on_remove, **kwargs):
super().__init__(master, fg_color=PALETTE["panel"],
corner_radius=12, border_width=1,
border_color=PALETTE["border"], **kwargs)
TimerCard.CARD_ID += 1
self._id = TimerCard.CARD_ID
self._on_remove = on_remove
self._running = False
self._paused = False
self._target_ts : float | None = None # unix timestamp
self._remaining : int = 0 # seconds
self._thread : threading.Thread | None = None
self._stop_evt = threading.Event()
self._build()
# ── 建構 UI ──────────────────────────────────────────
def _build(self):
# 頂列:標題 + 刪除
top = ctk.CTkFrame(self, fg_color="transparent")
top.pack(fill="x", padx=14, pady=(12, 0))
ctk.CTkLabel(top, text=f"計時器 #{self._id}",
font=("Consolas", 13, "bold"),
text_color=PALETTE["accent"]).pack(side="left")
ctk.CTkButton(top, text="✕", width=28, height=28,
font=("Consolas", 13),
fg_color=PALETTE["border"], hover_color=PALETTE["red"],
text_color=PALETTE["text"],
command=self._remove).pack(side="right")
# ── 模式選擇 ─────────────────────────────────────
mode_frame = ctk.CTkFrame(self, fg_color="transparent")
mode_frame.pack(fill="x", padx=14, pady=(8, 0))
self._mode = ctk.StringVar(value="minutes")
ctk.CTkRadioButton(mode_frame, text="幾分鐘後",
variable=self._mode, value="minutes",
font=("Consolas", 12),
text_color=PALETTE["text"],
command=self._mode_changed).pack(side="left", padx=(0,16))
ctk.CTkRadioButton(mode_frame, text="指定時刻",
variable=self._mode, value="clock",
font=("Consolas", 12),
text_color=PALETTE["text"],
command=self._mode_changed).pack(side="left")
# ── 輸入區(分鐘 / 時刻 切換) ───────────────────
self._input_frame = ctk.CTkFrame(self, fg_color="transparent")
self._input_frame.pack(fill="x", padx=14, pady=(6, 0))
self._build_minute_input()
# ── 提醒文字 ─────────────────────────────────────
ctk.CTkLabel(self, text="提醒文字",
font=("Consolas", 11),
text_color=PALETTE["subtext"]).pack(anchor="w", padx=14, pady=(8,0))
self._msg_entry = ctk.CTkEntry(self, placeholder_text="輸入提醒內容…",
font=("Consolas", 12),
fg_color=PALETTE["bg"],
border_color=PALETTE["border"],
text_color=PALETTE["text"])
self._msg_entry.pack(fill="x", padx=14)
# ── 倒數顯示 ─────────────────────────────────────
self._countdown_label = ctk.CTkLabel(
self, text="──:──:──",
font=("Consolas", 36, "bold"),
text_color=PALETTE["text"]
)
self._countdown_label.pack(pady=(10, 4))
self._status_label = ctk.CTkLabel(
self, text="待機中",
font=("Consolas", 11),
text_color=PALETTE["subtext"]
)
self._status_label.pack()
# ── 控制按鈕 ─────────────────────────────────────
btn_row = ctk.CTkFrame(self, fg_color="transparent")
btn_row.pack(pady=(8, 14))
btn_cfg = dict(width=80, height=34, corner_radius=8,
font=("Consolas", 12, "bold"))
self._start_btn = ctk.CTkButton(btn_row, text="▶ 開始",
fg_color=PALETTE["green"],
hover_color="#2ea043",
text_color="#fff",
command=self._start, **btn_cfg)
self._start_btn.pack(side="left", padx=4)
self._pause_btn = ctk.CTkButton(btn_row, text="⏸ 暫停",
fg_color=PALETTE["orange"],
hover_color="#d08030",
text_color="#fff",
state="disabled",
command=self._pause, **btn_cfg)
self._pause_btn.pack(side="left", padx=4)
self._reset_btn = ctk.CTkButton(btn_row, text="↺ 重置",
fg_color=PALETTE["border"],
hover_color="#484f58",
text_color=PALETTE["text"],
command=self._reset, **btn_cfg)
self._reset_btn.pack(side="left", padx=4)
# ── 模式輸入區 ───────────────────────────────────────
def _clear_input_frame(self):
for w in self._input_frame.winfo_children():
w.destroy()
def _build_minute_input(self):
self._clear_input_frame()
ctk.CTkLabel(self._input_frame, text="分鐘數",
font=("Consolas", 11),
text_color=PALETTE["subtext"]).pack(side="left", padx=(0,6))
self._min_var = ctk.StringVar(value="25")
vcmd = (self.register(lambda s: s.isdigit() or s == ""), "%P")
self._min_entry = ctk.CTkEntry(self._input_frame,
textvariable=self._min_var,
width=72, font=("Consolas", 14),
fg_color=PALETTE["bg"],
border_color=PALETTE["border"],
text_color=PALETTE["text"],
validate="key", validatecommand=vcmd)
self._min_entry.pack(side="left")
ctk.CTkLabel(self._input_frame, text="分",
font=("Consolas", 11),
text_color=PALETTE["subtext"]).pack(side="left", padx=(4,0))
# 快速按鈕
for m in (5, 10, 25, 60):
ctk.CTkButton(self._input_frame, text=f"+{m}",
width=36, height=26, corner_radius=6,
font=("Consolas", 11),
fg_color=PALETTE["border"],
hover_color="#484f58",
text_color=PALETTE["text"],
command=lambda v=m: self._add_minutes(v)
).pack(side="left", padx=2)
def _build_clock_input(self):
self._clear_input_frame()
ctk.CTkLabel(self._input_frame, text="時刻(HH:MM)",
font=("Consolas", 11),
text_color=PALETTE["subtext"]).pack(side="left", padx=(0,6))
now = datetime.now().strftime("%H:%M")
self._clock_var = ctk.StringVar(value=now)
self._clock_entry = ctk.CTkEntry(self._input_frame,
textvariable=self._clock_var,
width=80, font=("Consolas", 14),
fg_color=PALETTE["bg"],
border_color=PALETTE["border"],
text_color=PALETTE["text"],
placeholder_text="09:30")
self._clock_entry.pack(side="left")
def _mode_changed(self):
if self._mode.get() == "minutes":
self._build_minute_input()
else:
self._build_clock_input()
def _add_minutes(self, v):
try:
cur = int(self._min_var.get() or 0)
except ValueError:
cur = 0
self._min_var.set(str(cur + v))
# ── 計算目標時間 ─────────────────────────────────────
def _compute_target(self) -> tuple[bool, str]:
if self._mode.get() == "minutes":
try:
mins = int(self._min_var.get())
if mins <= 0:
return False, "請輸入大於 0 的分鐘數"
except ValueError:
return False, "分鐘數無效"
self._target_ts = time.time() + mins * 60
else:
raw = self._clock_var.get().strip()
try:
t = datetime.strptime(raw, "%H:%M")
now = datetime.now()
target = now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
self._target_ts = target.timestamp()
except ValueError:
return False, "時刻格式應為 HH:MM"
return True, ""
# ── 控制邏輯 ─────────────────────────────────────────
def _start(self):
if self._paused:
# 從暫停恢復:用剩餘秒數重新計算 target
self._target_ts = time.time() + self._remaining
self._paused = False
else:
ok, err = self._compute_target()
if not ok:
messagebox.showerror("輸入錯誤", err)
return
self._running = True
self._stop_evt.clear()
self._start_btn.configure(state="disabled")
self._pause_btn.configure(state="normal")
self._status_label.configure(text="計時中…", text_color=PALETTE["green"])
self._thread = threading.Thread(target=self._tick, daemon=True)
self._thread.start()
def _pause(self):
if not self._running:
return
self._running = False
self._paused = True
self._stop_evt.set()
self._start_btn.configure(state="normal", text="▶ 繼續")
self._pause_btn.configure(state="disabled")
self._status_label.configure(text="已暫停", text_color=PALETTE["orange"])
def _reset(self):
self._running = False
self._paused = False
self._target_ts = None
self._remaining = 0
self._stop_evt.set()
self._start_btn.configure(state="normal", text="▶ 開始")
self._pause_btn.configure(state="disabled")
self._countdown_label.configure(text="──:──:──",
text_color=PALETTE["text"])
self._status_label.configure(text="待機中", text_color=PALETTE["subtext"])
def _remove(self):
self._reset()
self._on_remove(self)
# ── 計時執行緒 ────────────────────────────────────────
def _tick(self):
while not self._stop_evt.is_set():
remaining = self._target_ts - time.time()
if remaining <= 0:
self._remaining = 0
self.after(0, self._done)
return
self._remaining = int(remaining)
self.after(0, self._update_display, self._remaining)
self._stop_evt.wait(0.5)
def _update_display(self, secs: int):
h = secs // 3600
m = (secs % 3600) // 60
s = secs % 60
self._countdown_label.configure(
text=f"{h:02d}:{m:02d}:{s:02d}",
text_color=PALETTE["red"] if secs <= 60 else PALETTE["text"]
)
def _done(self):
self._running = False
self._countdown_label.configure(text="00:00:00",
text_color=PALETTE["red"])
self._status_label.configure(text="時間到!", text_color=PALETTE["red"])
self._start_btn.configure(state="normal", text="▶ 開始")
self._pause_btn.configure(state="disabled")
msg = self._msg_entry.get().strip() or "時間到!"
self._show_notify(msg)
def _show_notify(self, msg: str):
win = ctk.CTkToplevel(self)
win.title("⏰ 提醒")
win.geometry("360x220")
win.configure(fg_color=PALETTE["bg"])
win.lift()
win.attributes("-topmost", True)
ctk.CTkLabel(win, text="⏰", font=("Segoe UI Emoji", 48)).pack(pady=(24, 4))
ctk.CTkLabel(win, text=msg, font=("Consolas", 16, "bold"),
text_color=PALETTE["text"],
wraplength=300).pack(pady=4)
ctk.CTkLabel(win, text=f"計時器 #{self._id}",
font=("Consolas", 11),
text_color=PALETTE["subtext"]).pack()
ctk.CTkButton(win, text="知道了", font=("Consolas", 13),
fg_color=PALETTE["accent"], hover_color="#1f6feb",
text_color="#fff", width=120,
command=win.destroy).pack(pady=16)
# ── 主視窗 ────────────────────────────────────────────────
class App(ctk.CTk):
def __init__(self):
super().__init__()
self.title("番茄計時器")
self.geometry("520x720")
self.minsize(460, 500)
self.configure(fg_color=PALETTE["bg"])
self._cards: list[TimerCard] = []
self._build()
def _build(self):
# 頂欄
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=20, pady=(18, 8))
ctk.CTkLabel(header, text="⏱ 番茄計時器",
font=("Consolas", 20, "bold"),
text_color=PALETTE["accent"]).pack(side="left")
ctk.CTkButton(header, text="+ 新增計時器",
width=130, height=34, corner_radius=8,
font=("Consolas", 12, "bold"),
fg_color=PALETTE["accent"], hover_color="#1f6feb",
text_color="#fff",
command=self._add_card).pack(side="right")
# 捲動區
self._scroll = ctk.CTkScrollableFrame(self, fg_color="transparent",
scrollbar_button_color=PALETTE["border"])
self._scroll.pack(fill="both", expand=True, padx=16, pady=(0, 16))
# 預設加一張卡片
self._add_card()
def _add_card(self):
card = TimerCard(self._scroll, on_remove=self._remove_card)
card.pack(fill="x", pady=8)
self._cards.append(card)
def _remove_card(self, card: TimerCard):
card.pack_forget()
card.destroy()
self._cards.remove(card)
if __name__ == "__main__":
app = App()
app.mainloop()