import tkinter as tk from tkinter import ttk, messagebox from datetime import datetime, timedelta, date import calendar as pycal import json import os import uuid import sys import traceback import shutil import tkinter.font as tkfont
def _possible_legacy_paths(): paths = [] try: script_dir = os.path.dirname(os.path.abspath(__file__)) paths.append(os.path.join(script_dir, "todo_reminder_data.json")) except Exception: pass try: paths.append(os.path.join(os.getcwd(), "todo_reminder_data.json")) except Exception: pass uniq = [] for p in paths: if p and p not in uniq: uniq.append(p) return uniq
def migrate_legacy_data_if_needed(): if os.path.exists(DATA_FILE): return for p in _possible_legacy_paths(): try: if os.path.exists(p): shutil.copy2(p, DATA_FILE) print(f"[migrate] copied legacy data from: {p} -> {DATA_FILE}") return except Exception: traceback.print_exc()
grid = ttk.Frame(outer) grid.pack() header = ["月","火","水","木","金","土","日"] for i, h in enumerate(header): ttk.Label(grid, text=h, width=4, anchor="center").grid(row=0, column=i, padx=2, pady=2)
self.day_buttons = [] for r in range(1, 7): row_btns = [] for c in range(7): b = ttk.Button(grid, text="", width=4) b.grid(row=r, column=c, padx=2, pady=2) row_btns.append(b) self.day_buttons.append(row_btns)
def render(self): self.lbl_ym.config(text=f"{self.cur_year}年 {self.cur_month:02d}月") monthcal = pycal.Calendar(firstweekday=0).monthdayscalendar(self.cur_year, self.cur_month) for r in range(6): week = monthcal[r] if r < len(monthcal) else [0]*7 for c in range(7): day = week[c] btn = self.day_buttons[r][c] if day == 0: btn.config(text="", state="disabled", command=None) else: btn.config(text=str(day), state="normal", command=lambda d=day: self.pick_day(d))
def get_selected_task(self): sel = self.tree.selection() if not sel: return None, None iid = sel[0] for t in self.tasks: if t["id"] == iid: return iid, t return None, None
def toggle_selected(self): iid, task = self.get_selected_task() if not task: return task["completed"] = not task["completed"] if task["completed"]: self._roll_task_to_next_if_repeating(task) else: task["alerted"] = False self._save_data() self.refresh_view()
def delete_selected(self): iid, task = self.get_selected_task() if not task: return if messagebox.askyesno("削除", "選択したタスクを削除しますか?"): self.tasks = [t for t in self.tasks if t["id"] != iid] self._save_data() self.refresh_view()
# --- Tree 操作 --- def on_double_click_edit(self, event): item = self.tree.identify_row(event.y) if not item: return for t in self.tasks: if t["id"] == item: try: dt = parse_deadline(t["deadline"]) preset_date = dt.strftime(DATE_FMT) preset_time = dt.strftime(TIME_FMT) except Exception: preset_date = date.today().strftime(DATE_FMT) preset_time = now_local().strftime(TIME_FMT) TaskDialog( self.root, title="タスクの編集", on_ok=lambda *args: self._edit_task_apply(item, *args), preset={ "title": t["title"], "priority": t["priority"], "date": preset_date, "time": preset_time, "repeat": t.get("repeat", "なし"), } ) break
def on_tree_click_toggle(self, event): region = self.tree.identify("region", event.x, event.y) if region != "cell": return col = self.tree.identify_column(event.x) # "#1" が先頭列(完了) if col != "#1": return row = self.tree.identify_row(event.y) if not row: return self.tree.selection_set(row) for t in self.tasks: if t["id"] == row: t["completed"] = not t["completed"] if t["completed"]: self._roll_task_to_next_if_repeating(t) else: t["alerted"] = False break self._save_data() self.refresh_view() return "break"
# --- フィルタ/ソート/統計 --- def _filtered_tasks(self): mode = self.filter_var.get() today = date.today() res = [] for t in self.tasks: try: dl = parse_deadline(t["deadline"]) except Exception: continue if mode == "すべて": res.append(t) elif mode == "未完了": if not t["completed"]: res.append(t) elif mode == "完了": if t["completed"]: res.append(t) elif mode == "今日が締切": if not t["completed"] and dl.date() == today: res.append(t) elif mode == "期限切れ": if not t["completed"] and dl < now_local(): res.append(t) res.sort(key=lambda x: (PRIORITY_ORDER.get(x["priority"], 9), parse_deadline(x["deadline"]))) return res
def _compute_stats(self): total = len(self.tasks) completed = sum(1 for t in self.tasks if t.get("completed")) overdue = 0 now_ = now_local() for t in self.tasks: try: dl = parse_deadline(t["deadline"]) if not t["completed"] and dl < now_: overdue += 1 except Exception: pass self.stats_var.set(f"全: {total} / 完了: {completed} / 期限切れ: {overdue}")
def refresh_view(self): for iid in self.tree.get_children(): self.tree.delete(iid) for t in self._filtered_tasks(): done = "☑" if t.get("completed") else "☐" pr = t.get("priority", "中") pr_disp = f"{PRIORITY_ICON.get(pr, '')}{pr}" dl_text = t.get("deadline", "") rep = t.get("repeat", "なし") rep_disp = REPEAT_SHORT.get(rep, "") title_raw = t.get("title", "") title = " ".join(title_raw.splitlines()).strip()
tags = [] if t.get("completed"): tags.append("completed") else: try: dl = parse_deadline(dl_text) if dl < now_local(): tags.append("overdue") except Exception: pass if pr == "高": tags.append("high")
candidates = [] for t in self.tasks: if t.get("completed"): continue if t["id"] in self.active_popups: continue try: dl = parse_deadline(t["deadline"]) except Exception: continue if t.get("alerted", False): continue
# --- 段積み列選択 --- def _choose_column_for_popup(self): col = min(range(self.tiling_cols), key=lambda i: self.tiling_next_y[i]) x = POPUP_MARGIN + col * (self.popup_w + POPUP_MARGIN) y = self.tiling_next_y[col] return col, x, y
# --- ボタン自動レイアウト(1行/2行/縦) --- def _layout_buttons(self, btns_frame: ttk.Frame, buttons, max_width: int) -> int: """ ボタンを1行/2行/縦並びに自動レイアウトする。returns: 使用した行数 """ for w in btns_frame.grid_slaves(): w.grid_forget() for i in range(10): try: btns_frame.grid_columnconfigure(i, weight=0) except Exception: pass
btns_frame.update_idletasks() widths = [b.winfo_reqwidth() for b in buttons] gap = 8 # 左右余白合計の簡易見積もり
# 1行で収まる? one_row = sum(widths) + gap * (len(buttons) + 1) if one_row <= max_width: for i, b in enumerate(buttons): b.grid(row=0, column=i, padx=4, pady=4, sticky="ew") btns_frame.grid_columnconfigure(i, weight=1) return 1
# 2行で試す(上段に ceil(n/2) 個) n = len(buttons) k = (n + 1) // 2 top_w = sum(widths[:k]) + gap * (k + 1) bot_w = sum(widths[k:]) + gap * (n - k + 1) if max(top_w, bot_w) <= max_width: for i, b in enumerate(buttons[:k]): b.grid(row=0, column=i, padx=4, pady=4, sticky="ew") btns_frame.grid_columnconfigure(i, weight=1) for i, b in enumerate(buttons[k:]): b.grid(row=1, column=i, padx=4, pady=4, sticky="ew") btns_frame.grid_columnconfigure(i, weight=1) return 2
# 最終手段:縦並び(確実に切れない) for r, b in enumerate(buttons): b.grid(row=r, column=0, padx=4, pady=4, sticky="ew") btns_frame.grid_columnconfigure(0, weight=1) return n
イトケンさんの投稿
(投稿ID: 5661)
今回はリマインダー付きTODO作成アプリを作ってみました。
TODOメモ(リマインド機能付き)
・TODOを書き込む、ポップアップする締切時間を登録する
・時間になると、書き込んだTODOがポップアップする
・日次、週次、月次の繰り返し処理も登録できる
・ショートカットキーが使える
新規(ctrl+N)
編集(ctrl+E)
こういうアプリを職場で配布できるようになりたいですね。
# todo_reminder.py
# ポップアップ見切れ永久対策 + マルチモニター/タスクバー位置/DPI対応
# - 表示先モニターの作業領域に高さ・位置をクランプ
# - 本体最小化中でも透明復帰→ポップ→再最小化で確実表示
# - グレース±15秒/起動直後即チェック
# - ボタンは幅に応じて 1行/2行/縦並びを自動選択(見切れ防止)
# 既存機能:作成/編集/削除、優先度、締切、繰り返し(日/週/月/年)、スヌーズ、複数整列、統計、フィルタ、保存
import tkinter as tk
from tkinter import ttk, messagebox
from datetime import datetime, timedelta, date
import calendar as pycal
import json
import os
import uuid
import sys
import traceback
import shutil
import tkinter.font as tkfont
# ---------------- 保存先と移行 ----------------
APP_DIR = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "TodoReminder")
os.makedirs(APP_DIR, exist_ok=True)
DATA_FILE = os.path.join(APP_DIR, "todo_reminder_data.json")
def _possible_legacy_paths():
paths = []
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
paths.append(os.path.join(script_dir, "todo_reminder_data.json"))
except Exception:
pass
try:
paths.append(os.path.join(os.getcwd(), "todo_reminder_data.json"))
except Exception:
pass
uniq = []
for p in paths:
if p and p not in uniq:
uniq.append(p)
return uniq
def migrate_legacy_data_if_needed():
if os.path.exists(DATA_FILE):
return
for p in _possible_legacy_paths():
try:
if os.path.exists(p):
shutil.copy2(p, DATA_FILE)
print(f"[migrate] copied legacy data from: {p} -> {DATA_FILE}")
return
except Exception:
traceback.print_exc()
# ---------------- 設定 ----------------
CHECK_INTERVAL_MS = 10_000 # 10秒ごとチェック
DUE_GRACE_SEC = 15 # 取りこぼし防止グレース(±15秒)
SNOOZE_MINUTES = 10
POPUP_W_DEFAULT = 480
POPUP_MARGIN = 16
PRIORITY_VALUES = ["高", "中", "低"]
PRIORITY_ICON = {"高": "🔴", "中": "🟡", "低": "🟢"}
PRIORITY_ORDER = {"高": 0, "中": 1, "低": 2}
FILTER_OPTIONS = ["すべて", "未完了", "完了", "今日が締切", "期限切れ"]
REPEAT_OPTIONS = ["なし", "日次", "週次", "月次", "年次"]
REPEAT_SHORT = {"なし": "", "日次": "日", "週次": "週", "月次": "月", "年次": "年"}
DATE_FMT = "%Y/%m/%d"
TIME_FMT = "%H:%M"
DATETIME_FMT = "%Y/%m/%d %H:%M"
# ---------------- ユーティリティ ----------------
def now_local():
return datetime.now()
def parse_deadline(s: str) -> datetime:
s = s.strip()
for fmt in ("%Y/%m/%d %H:%M", "%Y-%m-%d %H:%M", "%Y.%m.%d %H:%M"):
try:
return datetime.strptime(s, fmt)
except Exception:
pass
raise ValueError(f"日時の形式が正しくありません: {s}\n例: 2025/08/23 17:30")
def format_deadline(dt: datetime) -> str:
return dt.strftime(DATETIME_FMT)
def safe_load_json(path: str, default):
if not os.path.exists(path):
return default
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
traceback.print_exc()
return default
def safe_save_json(path: str, data):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
traceback.print_exc()
# ====== フォント拡大 ======
def apply_global_font_scaling(base_pt: int = 16, heading_delta: int = 3):
for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkFixedFont"):
f = tkfont.nametofont(name)
f.configure(size=base_pt)
tkfont.nametofont("TkHeadingFont").configure(size=base_pt + heading_delta)
text_f = tkfont.nametofont("TkTextFont")
row_h = max(int(text_f.metrics("linespace") * 1.6), 26)
style = ttk.Style()
style.configure("Treeview", rowheight=row_h)
style.configure("Treeview.Heading", padding=(8, 6))
# ====== 月/年ユーティリティ ======
def last_day_of_month(y: int, m: int) -> int:
return pycal.monthrange(y, m)[1]
def add_months(dt: datetime, n: int) -> datetime:
y, m = dt.year, dt.month
m += n
y += (m - 1) // 12
m = (m - 1) % 12 + 1
d = min(dt.day, last_day_of_month(y, m))
return dt.replace(year=y, month=m, day=d)
def add_years(dt: datetime, n: int) -> datetime:
y = dt.year + n
m = dt.month
d = min(dt.day, last_day_of_month(y, m))
return dt.replace(year=y, month=m, day=d)
def next_occurrence(dt: datetime, repeat: str) -> datetime:
if repeat == "日次":
return dt + timedelta(days=1)
if repeat == "週次":
return dt + timedelta(weeks=1)
if repeat == "月次":
return add_months(dt, 1)
if repeat == "年次":
return add_years(dt, 1)
return dt
def roll_forward_into_future(dt: datetime, repeat: str, reference: datetime) -> datetime:
if repeat == "なし":
return dt
limit = 1000
while dt <= reference and limit > 0:
dt = next_occurrence(dt, repeat)
limit -= 1
return dt
# ====== Windows 作業領域(タスクバー除外) ======
def _get_windows_workarea():
if not sys.platform.startswith("win"):
return 0, 0, None, None
try:
from ctypes import wintypes, windll, byref
SPI_GETWORKAREA = 0x0030
rect = wintypes.RECT()
ok = windll.user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, byref(rect), 0)
if ok:
left, top, right, bottom = rect.left, rect.top, rect.right, rect.bottom
return left, top, right - left, bottom - top
except Exception:
pass
return 0, 0, None, None
# ========== カレンダー ==========
class CalendarPopup:
def __init__(self, master, init_date: date, on_pick):
self.top = tk.Toplevel(master)
self.top.title("日付を選択")
self.top.transient(master)
self.on_pick = on_pick
try:
self.top.geometry("+380+240")
except Exception:
pass
self.cur_year = init_date.year
self.cur_month = init_date.month
outer = ttk.Frame(self.top, padding=10)
outer.pack(fill=tk.BOTH, expand=True)
nav = ttk.Frame(outer)
nav.pack(fill=tk.X, pady=(0, 6))
ttk.Button(nav, text="◀", width=3, command=self.prev_month).pack(side=tk.LEFT)
ttk.Button(nav, text="▶", width=3, command=self.next_month).pack(side=tk.RIGHT)
self.lbl_ym = ttk.Label(nav, text="")
self.lbl_ym.pack(side=tk.LEFT, expand=True)
grid = ttk.Frame(outer)
grid.pack()
header = ["月","火","水","木","金","土","日"]
for i, h in enumerate(header):
ttk.Label(grid, text=h, width=4, anchor="center").grid(row=0, column=i, padx=2, pady=2)
self.day_buttons = []
for r in range(1, 7):
row_btns = []
for c in range(7):
b = ttk.Button(grid, text="", width=4)
b.grid(row=r, column=c, padx=2, pady=2)
row_btns.append(b)
self.day_buttons.append(row_btns)
ctrl = ttk.Frame(outer)
ctrl.pack(fill=tk.X, pady=(8, 0))
ttk.Button(ctrl, text="今日", command=self.pick_today).pack(side=tk.LEFT)
ttk.Button(ctrl, text="キャンセル", command=self.close).pack(side=tk.RIGHT)
self.render()
self.top.bind("<Escape>", lambda e: self.close())
def render(self):
self.lbl_ym.config(text=f"{self.cur_year}年 {self.cur_month:02d}月")
monthcal = pycal.Calendar(firstweekday=0).monthdayscalendar(self.cur_year, self.cur_month)
for r in range(6):
week = monthcal[r] if r < len(monthcal) else [0]*7
for c in range(7):
day = week[c]
btn = self.day_buttons[r][c]
if day == 0:
btn.config(text="", state="disabled", command=None)
else:
btn.config(text=str(day), state="normal", command=lambda d=day: self.pick_day(d))
def prev_month(self):
if self.cur_month == 1:
self.cur_month = 12
self.cur_year -= 1
else:
self.cur_month -= 1
self.render()
def next_month(self):
if self.cur_month == 12:
self.cur_month = 1
self.cur_year += 1
else:
self.cur_month += 1
self.render()
def pick_day(self, day):
self.on_pick(date(self.cur_year, self.cur_month, day))
self.close()
def pick_today(self):
self.on_pick(date.today())
self.close()
def close(self):
try:
self.top.destroy()
except Exception:
pass
# ========== メイン ==========
class TodoApp:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("TODO + リマインダー(Tkinter)")
try:
self.root.iconbitmap("") # 失敗しても無視
except Exception:
pass
# 画面情報
self.root.update_idletasks()
self.screen_w = self.root.winfo_screenwidth()
self.screen_h = self.root.winfo_screenheight()
self.work_left, self.work_top, self.work_w, self.work_h = _get_windows_workarea()
self.effective_h = (self.work_h or self.screen_h)
self.top_margin = POPUP_MARGIN + (self.work_top or 0)
self.tasks = []
self.active_popups = {} # task_id -> Toplevel
# ポップ幅と段積み
self.popup_w = self._get_popup_width()
self.tiling_cols = max(1, self.screen_w // (self.popup_w + POPUP_MARGIN))
self.tiling_next_y = [self.top_margin] * self.tiling_cols
self.root.geometry("1100x720")
self._build_ui()
self._bind_keys()
migrate_legacy_data_if_needed()
self._load_data()
self._migrate_data()
self.refresh_view()
self._update_minsize()
# 起動直後即チェック
self.root.after(0, self._check_reminders)
self._schedule_check()
def _get_popup_width(self):
avail_w = (self.work_w or self.screen_w)
return max(320, min(POPUP_W_DEFAULT, avail_w - 2 * POPUP_MARGIN))
# --- UI ---
def _build_ui(self):
topbar = ttk.Frame(self.root, padding=6)
topbar.pack(fill=tk.X)
self.btn_add = ttk.Button(topbar, text="新規 (Ctrl+N)", command=self.add_task_dialog)
self.btn_edit = ttk.Button(topbar, text="編集 (Ctrl+E)", command=self.edit_selected)
self.btn_delete = ttk.Button(topbar, text="削除 (Del)", command=self.delete_selected)
for b in (self.btn_add, self.btn_edit, self.btn_delete):
b.pack(side=tk.LEFT, padx=2)
ttk.Separator(topbar, orient="vertical").pack(side=tk.LEFT, fill=tk.Y, padx=8)
ttk.Label(topbar, text="フィルター:").pack(side=tk.LEFT, padx=(0, 4))
self.filter_var = tk.StringVar(value=FILTER_OPTIONS[0])
self.filter_combo = ttk.Combobox(topbar, textvariable=self.filter_var, values=FILTER_OPTIONS, width=12, state="readonly")
self.filter_combo.pack(side=tk.LEFT)
self.filter_combo.bind("<<ComboboxSelected>>", lambda e: self.refresh_view())
ttk.Separator(topbar, orient="vertical").pack(side=tk.LEFT, fill=tk.Y, padx=8)
self.stats_var = tk.StringVar(value="全: 0 / 完了: 0 / 期限切れ: 0")
ttk.Label(topbar, textvariable=self.stats_var).pack(side=tk.LEFT, padx=4)
# Treeview + スクロール
tree_wrap = ttk.Frame(self.root)
tree_wrap.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
columns = ("done", "priority", "deadline", "repeat", "title")
self.tree = ttk.Treeview(tree_wrap, columns=columns, show="headings", selectmode="browse")
self.tree.heading("done", text="完了", anchor="center")
self.tree.heading("priority", text="優先度", anchor="center")
self.tree.heading("deadline", text="締切", anchor="center")
self.tree.heading("repeat", text="繰り返し", anchor="center")
self.tree.heading("title", text="内容", anchor="w")
self.tree.column("done", anchor=tk.CENTER, stretch=False, width=60)
self.tree.column("priority", anchor=tk.CENTER, stretch=False, width=100)
self.tree.column("deadline", anchor=tk.CENTER, stretch=False, width=190)
self.tree.column("repeat", anchor=tk.CENTER, stretch=False, width=90)
self.tree.column("title", anchor=tk.W, stretch=True, width=620)
yscroll = ttk.Scrollbar(tree_wrap, orient="vertical", command=self.tree.yview)
xscroll = ttk.Scrollbar(tree_wrap, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
self.tree.grid(row=0, column=0, sticky="nsew")
yscroll.grid(row=0, column=1, sticky="ns")
xscroll.grid(row=1, column=0, sticky="ew")
tree_wrap.rowconfigure(0, weight=1)
tree_wrap.columnconfigure(0, weight=1)
self.tree.tag_configure("completed", foreground="#888")
self.tree.tag_configure("overdue", background="#ffe6e6")
self.tree.tag_configure("high", foreground="#d00000")
self.tree.bind("<Double-1>", self.on_double_click_edit)
self.tree.bind("<Button-1>", self.on_tree_click_toggle)
self.hint_lbl = ttk.Label(
self.root, padding=(8, 0),
text=("ダブルクリックで編集 / Ctrl+N新規 / Space完了 / Del削除 / "
"📅で日付選択・時刻は現在時刻から微調整 / 繰り返し:日・週・月・年 / "
"同時刻の予定は複数ポップアップで整列表示(本文スクロール対応)")
)
self.hint_lbl.pack(fill=tk.X)
self.root.bind("<Configure>", self._on_resize_adjust_columns)
def _bind_keys(self):
self.root.bind("<Control-n>", lambda e: self.add_task_dialog())
self.root.bind("<Control-N>", lambda e: self.add_task_dialog())
self.root.bind("<Control-e>", lambda e: self.edit_selected())
self.root.bind("<Control-E>", lambda e: self.edit_selected())
self.root.bind("<space>", lambda e: self.toggle_selected())
self.root.bind("<Delete>", lambda e: self.delete_selected())
# --- ウィンドウ最小サイズ ---
def _update_minsize(self):
f_head = tkfont.nametofont("TkHeadingFont")
pad = 28
done_w = max(60, f_head.measure("完了") + pad)
pr_w = max(100, f_head.measure("優先度") + pad)
dl_w = max(190, f_head.measure("0000/00/00 00:00") + pad)
rp_w = max(90, f_head.measure("繰り返し") + pad)
fixed_w = done_w + pr_w + dl_w + rp_w
title_min = max(260, f_head.measure("内容") + 200)
min_w = min(fixed_w + title_min + 60, self.screen_w - 40)
min_h = min(560, self.effective_h - 80)
self.root.minsize(min_w, min_h)
# --- 列幅調整&ヒント折返し ---
def _on_resize_adjust_columns(self, event=None):
try:
f_head = tkfont.nametofont("TkHeadingFont")
pad = 28
done_w = max(60, f_head.measure("完了") + pad)
pr_w = max(100, f_head.measure("優先度") + pad)
dl_w = max(190, f_head.measure("0000/00/00 00:00") + pad)
rp_w = max(90, f_head.measure("繰り返し") + pad)
tw = self.tree.winfo_width()
fixed = done_w + pr_w + dl_w + rp_w
title_w = max(240, tw - fixed - 8) if tw > 0 else 620
self.tree.column("done", width=done_w)
self.tree.column("priority", width=pr_w)
self.tree.column("deadline", width=dl_w)
self.tree.column("repeat", width=rp_w)
self.tree.column("title", width=title_w)
wrap = max(200, self.root.winfo_width() - 40)
self.hint_lbl.configure(wraplength=wrap)
except Exception:
pass
# --- データ ---
def _load_data(self):
data = safe_load_json(DATA_FILE, {"tasks": []})
self.tasks = data.get("tasks", [])
def _migrate_data(self):
for t in self.tasks:
t.setdefault("id", str(uuid.uuid4()))
t.setdefault("completed", False)
t.setdefault("snooze_until", None)
t.setdefault("alerted", False)
t.setdefault("repeat", "なし")
def _save_data(self):
safe_save_json(DATA_FILE, {"tasks": self.tasks})
# --- タスク操作 ---
def add_task_dialog(self, preset=None):
TaskDialog(self.root, title="タスクの追加", on_ok=self._add_task_from_dialog, preset=preset)
def _add_task_from_dialog(self, title: str, priority: str, date_str: str, time_str: str, repeat: str):
try:
dt = datetime.strptime(date_str.strip() + " " + time_str.strip(), DATETIME_FMT)
except Exception:
messagebox.showerror(
"エラー",
f"日付/時刻の形式が正しくありません。\n例: {date.today().strftime(DATE_FMT)} と {now_local().strftime(TIME_FMT)}"
)
else:
new_task = {
"id": str(uuid.uuid4()),
"title": title.strip(),
"priority": priority,
"deadline": dt.strftime(DATETIME_FMT),
"completed": False,
"snooze_until": None,
"alerted": False,
"repeat": repeat,
}
self.tasks.append(new_task)
self._save_data()
self.refresh_view()
def get_selected_task(self):
sel = self.tree.selection()
if not sel:
return None, None
iid = sel[0]
for t in self.tasks:
if t["id"] == iid:
return iid, t
return None, None
def edit_selected(self):
iid, task = self.get_selected_task()
if not task:
return
try:
dt = parse_deadline(task["deadline"])
preset_date = dt.strftime(DATE_FMT)
preset_time = dt.strftime(TIME_FMT)
except Exception:
preset_date = date.today().strftime(DATE_FMT)
preset_time = now_local().strftime(TIME_FMT)
TaskDialog(
self.root,
title="タスクの編集",
on_ok=lambda *args: self._edit_task_apply(iid, *args),
preset={
"title": task["title"],
"priority": task["priority"],
"date": preset_date,
"time": preset_time,
"repeat": task.get("repeat", "なし"),
}
)
def _edit_task_apply(self, task_id: str, title: str, priority: str, date_str: str, time_str: str, repeat: str):
try:
dt = datetime.strptime(date_str.strip() + " " + time_str.strip(), DATETIME_FMT)
except Exception:
messagebox.showerror(
"エラー",
f"日付/時刻の形式が正しくありません。\n例: {date.today().strftime(DATE_FMT)} と {now_local().strftime(TIME_FMT)}"
)
return
for t in self.tasks:
if t["id"] == task_id:
t["title"] = title.strip()
t["priority"] = priority
t["deadline"] = dt.strftime(DATETIME_FMT)
t["repeat"] = repeat
t["alerted"] = False
t["snooze_until"] = None
break
self._save_data()
self.refresh_view()
def _roll_task_to_next_if_repeating(self, task):
if task.get("repeat", "なし") != "なし":
cur = parse_deadline(task["deadline"])
nxt = roll_forward_into_future(next_occurrence(cur, task["repeat"]), task["repeat"], now_local())
task["deadline"] = format_deadline(nxt)
task["completed"] = False
task["snooze_until"] = None
task["alerted"] = False
else:
task["snooze_until"] = None
task["alerted"] = True
def toggle_selected(self):
iid, task = self.get_selected_task()
if not task:
return
task["completed"] = not task["completed"]
if task["completed"]:
self._roll_task_to_next_if_repeating(task)
else:
task["alerted"] = False
self._save_data()
self.refresh_view()
def delete_selected(self):
iid, task = self.get_selected_task()
if not task:
return
if messagebox.askyesno("削除", "選択したタスクを削除しますか?"):
self.tasks = [t for t in self.tasks if t["id"] != iid]
self._save_data()
self.refresh_view()
# --- Tree 操作 ---
def on_double_click_edit(self, event):
item = self.tree.identify_row(event.y)
if not item:
return
for t in self.tasks:
if t["id"] == item:
try:
dt = parse_deadline(t["deadline"])
preset_date = dt.strftime(DATE_FMT)
preset_time = dt.strftime(TIME_FMT)
except Exception:
preset_date = date.today().strftime(DATE_FMT)
preset_time = now_local().strftime(TIME_FMT)
TaskDialog(
self.root,
title="タスクの編集",
on_ok=lambda *args: self._edit_task_apply(item, *args),
preset={
"title": t["title"],
"priority": t["priority"],
"date": preset_date,
"time": preset_time,
"repeat": t.get("repeat", "なし"),
}
)
break
def on_tree_click_toggle(self, event):
region = self.tree.identify("region", event.x, event.y)
if region != "cell":
return
col = self.tree.identify_column(event.x) # "#1" が先頭列(完了)
if col != "#1":
return
row = self.tree.identify_row(event.y)
if not row:
return
self.tree.selection_set(row)
for t in self.tasks:
if t["id"] == row:
t["completed"] = not t["completed"]
if t["completed"]:
self._roll_task_to_next_if_repeating(t)
else:
t["alerted"] = False
break
self._save_data()
self.refresh_view()
return "break"
# --- フィルタ/ソート/統計 ---
def _filtered_tasks(self):
mode = self.filter_var.get()
today = date.today()
res = []
for t in self.tasks:
try:
dl = parse_deadline(t["deadline"])
except Exception:
continue
if mode == "すべて":
res.append(t)
elif mode == "未完了":
if not t["completed"]:
res.append(t)
elif mode == "完了":
if t["completed"]:
res.append(t)
elif mode == "今日が締切":
if not t["completed"] and dl.date() == today:
res.append(t)
elif mode == "期限切れ":
if not t["completed"] and dl < now_local():
res.append(t)
res.sort(key=lambda x: (PRIORITY_ORDER.get(x["priority"], 9),
parse_deadline(x["deadline"])))
return res
def _compute_stats(self):
total = len(self.tasks)
completed = sum(1 for t in self.tasks if t.get("completed"))
overdue = 0
now_ = now_local()
for t in self.tasks:
try:
dl = parse_deadline(t["deadline"])
if not t["completed"] and dl < now_:
overdue += 1
except Exception:
pass
self.stats_var.set(f"全: {total} / 完了: {completed} / 期限切れ: {overdue}")
def refresh_view(self):
for iid in self.tree.get_children():
self.tree.delete(iid)
for t in self._filtered_tasks():
done = "☑" if t.get("completed") else "☐"
pr = t.get("priority", "中")
pr_disp = f"{PRIORITY_ICON.get(pr, '')}{pr}"
dl_text = t.get("deadline", "")
rep = t.get("repeat", "なし")
rep_disp = REPEAT_SHORT.get(rep, "")
title_raw = t.get("title", "")
title = " ".join(title_raw.splitlines()).strip()
tags = []
if t.get("completed"):
tags.append("completed")
else:
try:
dl = parse_deadline(dl_text)
if dl < now_local():
tags.append("overdue")
except Exception:
pass
if pr == "高":
tags.append("high")
self.tree.insert("", tk.END, iid=t["id"],
values=(done, pr_disp, dl_text, rep_disp, title),
tags=tuple(tags))
self._compute_stats()
self._on_resize_adjust_columns()
# --- 自動ロール(取りこぼし対策) ---
def _auto_roll_repeating_tasks_if_missed(self):
now_ = now_local()
changed = False
for t in self.tasks:
if t.get("completed"):
continue
repeat = t.get("repeat", "なし")
if repeat == "なし":
continue
try:
dl = parse_deadline(t["deadline"])
except Exception:
continue
if t.get("alerted", False) and dl < now_:
nxt = roll_forward_into_future(next_occurrence(dl, repeat), repeat, now_)
if nxt > dl:
t["deadline"] = format_deadline(nxt)
t["alerted"] = False
t["snooze_until"] = None
changed = True
if changed:
self._save_data()
self.refresh_view()
# --- チェックスケジュール ---
def _schedule_check(self):
self.root.after(CHECK_INTERVAL_MS, self._check_reminders)
def _check_reminders(self):
try:
self._auto_roll_repeating_tasks_if_missed()
due_list = self._find_due_tasks_for_popups()
for task in due_list:
try:
self._show_popup(task)
except Exception:
traceback.print_exc()
except Exception:
traceback.print_exc()
finally:
self._schedule_check()
def _find_due_tasks_for_popups(self):
now_ = now_local()
grace_end = now_ + timedelta(seconds=DUE_GRACE_SEC)
candidates = []
for t in self.tasks:
if t.get("completed"):
continue
if t["id"] in self.active_popups:
continue
try:
dl = parse_deadline(t["deadline"])
except Exception:
continue
if t.get("alerted", False):
continue
snooze_until = None
if t.get("snooze_until"):
try:
snooze_until = parse_deadline(t["snooze_until"])
except Exception:
snooze_until = None
if dl <= grace_end and (snooze_until is None or snooze_until <= grace_end):
candidates.append(t)
candidates.sort(key=lambda x: (PRIORITY_ORDER.get(x["priority"], 9),
parse_deadline(x["deadline"])))
return candidates
# --- 親が最小化でもポップできるように ---
def _prepare_parent_for_popup(self):
state = None
try:
state = self.root.state()
except Exception:
state = "normal"
need_restore = state in ("iconic", "withdrawn")
orig_alpha = None
if need_restore:
try:
orig_alpha = self.root.attributes("-alpha")
except Exception:
orig_alpha = None
try:
self.root.attributes("-alpha", 0.0) # 透明
except Exception:
pass
try:
self.root.deiconify()
except Exception:
pass
def restore():
if need_restore:
try:
if orig_alpha is not None:
self.root.attributes("-alpha", orig_alpha)
except Exception:
pass
try:
self.root.iconify()
except Exception:
pass
return restore
# --- 表示先モニターの作業領域を取得 ---
def _workarea_for_point(self, x, y):
"""(x,y) が属するモニターの作業領域(left, top, width, height)"""
if not sys.platform.startswith("win"):
return 0, 0, self.root.winfo_screenwidth(), self.root.winfo_screenheight()
try:
import ctypes
from ctypes import wintypes, byref
MONITOR_DEFAULTTONEAREST = 2
user32 = ctypes.windll.user32
class POINT(ctypes.Structure):
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
class RECT(ctypes.Structure):
_fields_ = [("left", wintypes.LONG), ("top", wintypes.LONG),
("right", wintypes.LONG), ("bottom", wintypes.LONG)]
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD),
("rcMonitor", RECT),
("rcWork", RECT),
("dwFlags", wintypes.DWORD)]
pt = POINT(int(x), int(y))
hmon = user32.MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST)
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
if user32.GetMonitorInfoW(hmon, byref(mi)):
left = mi.rcWork.left
top = mi.rcWork.top
width = mi.rcWork.right - mi.rcWork.left
height = mi.rcWork.bottom - mi.rcWork.top
return left, top, width, height
except Exception:
pass
return 0, 0, self.root.winfo_screenwidth(), self.root.winfo_screenheight()
# --- 段積み列選択 ---
def _choose_column_for_popup(self):
col = min(range(self.tiling_cols), key=lambda i: self.tiling_next_y[i])
x = POPUP_MARGIN + col * (self.popup_w + POPUP_MARGIN)
y = self.tiling_next_y[col]
return col, x, y
# --- ボタン自動レイアウト(1行/2行/縦) ---
def _layout_buttons(self, btns_frame: ttk.Frame, buttons, max_width: int) -> int:
"""
ボタンを1行/2行/縦並びに自動レイアウトする。returns: 使用した行数
"""
for w in btns_frame.grid_slaves():
w.grid_forget()
for i in range(10):
try:
btns_frame.grid_columnconfigure(i, weight=0)
except Exception:
pass
btns_frame.update_idletasks()
widths = [b.winfo_reqwidth() for b in buttons]
gap = 8 # 左右余白合計の簡易見積もり
# 1行で収まる?
one_row = sum(widths) + gap * (len(buttons) + 1)
if one_row <= max_width:
for i, b in enumerate(buttons):
b.grid(row=0, column=i, padx=4, pady=4, sticky="ew")
btns_frame.grid_columnconfigure(i, weight=1)
return 1
# 2行で試す(上段に ceil(n/2) 個)
n = len(buttons)
k = (n + 1) // 2
top_w = sum(widths[:k]) + gap * (k + 1)
bot_w = sum(widths[k:]) + gap * (n - k + 1)
if max(top_w, bot_w) <= max_width:
for i, b in enumerate(buttons[:k]):
b.grid(row=0, column=i, padx=4, pady=4, sticky="ew")
btns_frame.grid_columnconfigure(i, weight=1)
for i, b in enumerate(buttons[k:]):
b.grid(row=1, column=i, padx=4, pady=4, sticky="ew")
btns_frame.grid_columnconfigure(i, weight=1)
return 2
# 最終手段:縦並び(確実に切れない)
for r, b in enumerate(buttons):
b.grid(row=r, column=0, padx=4, pady=4, sticky="ew")
btns_frame.grid_columnconfigure(0, weight=1)
return n
# --- ポップ表示 ---
def _show_popup(self, task):
restore_parent = self._prepare_parent_for_popup()
col, x, y = self._choose_column_for_popup()
popup = tk.Toplevel(self.root)
popup.title("リマインダー")
popup.attributes("-topmost", True)
popup.geometry(f"+{int(x)}+{int(y)}")
popup.lift()
popup.after(120, lambda: popup.attributes("-topmost", True))
try:
self.root.bell()
except Exception:
pass
outer = ttk.Frame(popup, padding=16)
outer.pack(fill=tk.BOTH, expand=True)
# 本文(スクロール可)
content = ttk.Frame(outer)
content.pack(fill=tk.BOTH, expand=True)
msg = (
f"件名: {task['title']}\n"
f"優先度: {task['priority']}\n"
f"締切: {task['deadline']}\n"
f"繰り返し: {task.get('repeat','なし')}"
)
txt = tk.Text(content, wrap="word", height=8)
txt.insert("1.0", msg)
txt.configure(state="disabled")
scy = ttk.Scrollbar(content, orient="vertical", command=txt.yview)
txt.configure(yscrollcommand=scy.set)
txt.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scy.pack(side=tk.RIGHT, fill=tk.Y)
# ====== ボタン(自動改行レイアウト) ======
btns = ttk.Frame(outer)
btns.pack(fill=tk.X, pady=(12, 0))
def _close_and_unregister():
try:
popup.destroy()
finally:
self.active_popups.pop(task["id"], None)
def finish_and_close():
if task.get("repeat", "なし") != "なし":
cur = parse_deadline(task["deadline"])
nxt = roll_forward_into_future(next_occurrence(cur, task["repeat"]), task["repeat"], now_local())
task["deadline"] = format_deadline(nxt)
task["completed"] = False
task["snooze_until"] = None
task["alerted"] = False
else:
task["completed"] = True
task["snooze_until"] = None
task["alerted"] = True
self._save_data()
self.refresh_view()
_close_and_unregister()
def snooze_and_close():
next_time = now_local() + timedelta(minutes=SNOOZE_MINUTES)
task["snooze_until"] = format_deadline(next_time)
task["alerted"] = False
self._save_data()
self.refresh_view()
_close_and_unregister()
def skip_to_next_and_close():
cur = parse_deadline(task["deadline"])
nxt = roll_forward_into_future(next_occurrence(cur, task.get("repeat", "なし")),
task.get("repeat", "なし"), now_local())
task["deadline"] = format_deadline(nxt)
task["completed"] = False
task["snooze_until"] = None
task["alerted"] = False
self._save_data()
self.refresh_view()
_close_and_unregister()
def just_close():
repeat = task.get("repeat", "なし")
if repeat != "なし":
cur = parse_deadline(task["deadline"])
nxt = roll_forward_into_future(next_occurrence(cur, repeat), repeat, now_local())
task["deadline"] = format_deadline(nxt)
task["completed"] = False
task["snooze_until"] = None
task["alerted"] = False
else:
task["alerted"] = True
self._save_data()
self.refresh_view()
_close_and_unregister()
btn_finish = ttk.Button(btns, text="完了にする", command=finish_and_close)
btn_snooze = ttk.Button(btns, text=f"スヌーズ({SNOOZE_MINUTES}分)", command=snooze_and_close)
buttons = [btn_finish, btn_snooze]
if task.get("repeat", "なし") != "なし":
btn_skip = ttk.Button(btns, text="次回へ延期(繰り返し)", command=skip_to_next_and_close)
buttons.append(btn_skip)
btn_close = ttk.Button(btns, text="閉じる", command=just_close)
buttons.append(btn_close)
popup.update_idletasks()
max_btnbar_width = self.popup_w - 2 * 16 # outer左右paddingを考慮
_ = self._layout_buttons(btns, buttons, max_btnbar_width)
# ====== 高さ・位置クランプ(ボタンは必ず画面内) ======
popup.update_idletasks()
wa_left, wa_top, wa_w, wa_h = self._workarea_for_point(x, y)
safe_h_max = max(320, wa_h - 2 * POPUP_MARGIN)
safe_top_min = wa_top + POPUP_MARGIN
needed_h = outer.winfo_reqheight()
if needed_h > safe_h_max:
try:
outer.configure(padding=12)
popup.update_idletasks()
needed_h = outer.winfo_reqheight()
except Exception:
pass
used_h = min(needed_h, safe_h_max)
max_top = wa_top + wa_h - POPUP_MARGIN - used_h
y = max(safe_top_min, min(y, max_top))
popup.geometry(f"{self.popup_w}x{int(used_h)}+{int(x)}+{int(y)}")
# 段積み位置更新
self.tiling_next_y[col] = max(self.tiling_next_y[col], y + used_h + POPUP_MARGIN)
# 表示中登録
self.active_popups[task["id"]] = popup
popup.protocol("WM_DELETE_WINDOW", just_close)
popup.bind("<Escape>", lambda e: just_close())
# 親を元に戻す(必要時のみ)
self.root.after(150, restore_parent)
# ========== 入力ダイアログ ==========
class TaskDialog:
def __init__(self, master, title: str, on_ok, preset=None):
self.top = tk.Toplevel(master)
self.top.title(title)
self.top.transient(master)
self.top.grab_set()
self.on_ok = on_ok
try:
self.top.geometry("+360+220")
except Exception:
pass
frm = ttk.Frame(self.top, padding=16)
frm.pack(fill=tk.BOTH, expand=True)
ttk.Label(frm, text="内容:").grid(row=0, column=0, sticky="nw")
self.txt_title = tk.Text(frm, height=5, wrap="word")
self.txt_title.grid(row=0, column=1, columnspan=4, sticky="nsew", pady=4)
scroll_y = ttk.Scrollbar(frm, orient="vertical", command=self.txt_title.yview)
scroll_y.grid(row=0, column=5, sticky="ns")
self.txt_title.configure(yscrollcommand=scroll_y.set)
init_text = (preset or {}).get("title", "")
if init_text:
self.txt_title.insert("1.0", init_text)
ttk.Label(frm, text="優先度:").grid(row=1, column=0, sticky="w")
self.priority_var = tk.StringVar(value=(preset or {}).get("priority", "中"))
self.cmb_priority = ttk.Combobox(frm, textvariable=self.priority_var, values=PRIORITY_VALUES, width=8, state="readonly")
self.cmb_priority.grid(row=1, column=1, sticky="w", pady=4)
ttk.Label(frm, text="繰り返し:").grid(row=1, column=2, sticky="w")
self.repeat_var = tk.StringVar(value=(preset or {}).get("repeat", "なし"))
self.cmb_repeat = ttk.Combobox(frm, textvariable=self.repeat_var, values=REPEAT_OPTIONS, width=10, state="readonly")
self.cmb_repeat.grid(row=1, column=3, sticky="w", pady=4)
ttk.Label(frm, text="締切:").grid(row=2, column=0, sticky="w")
init_date = (preset or {}).get("date", date.today().strftime(DATE_FMT))
self.date_var = tk.StringVar(value=init_date)
self.ent_date = ttk.Entry(frm, textvariable=self.date_var, width=12)
self.ent_date.grid(row=2, column=1, sticky="w", pady=4)
ttk.Button(frm, text="📅 日付選択…", command=self.open_calendar).grid(row=2, column=2, sticky="w")
ttk.Label(frm, text="時刻:").grid(row=3, column=0, sticky="w")
init_time = (preset or {}).get("time", now_local().strftime(TIME_FMT))
self.time_var = tk.StringVar(value=init_time)
self.ent_time = ttk.Entry(frm, textvariable=self.time_var, width=8)
self.ent_time.grid(row=3, column=1, sticky="w", pady=4)
ttk.Label(frm, text="例: 09:30 / 17:45").grid(row=3, column=2, sticky="w")
btns = ttk.Frame(frm)
btns.grid(row=4, column=0, columnspan=6, pady=(12, 0), sticky="e")
ttk.Button(btns, text="OK", command=self._ok).pack(side=tk.LEFT, padx=4)
ttk.Button(btns, text="キャンセル", command=self._cancel).pack(side=tk.LEFT, padx=4)
self.top.bind("<Control-Return>", lambda e: self._ok())
self.top.bind("<Escape>", lambda e: self._cancel())
self.txt_title.focus_set()
frm.columnconfigure(1, weight=1)
frm.rowconfigure(0, weight=1)
def open_calendar(self):
try:
base_d = datetime.strptime(self.date_var.get().strip(), DATE_FMT).date()
except Exception:
base_d = date.today()
CalendarPopup(self.top, init_date=base_d, on_pick=self._set_date_from_calendar)
def _set_date_from_calendar(self, d: date):
self.date_var.set(d.strftime(DATE_FMT))
def _ok(self):
title = self.txt_title.get("1.0", "end-1c").strip()
priority = self.priority_var.get()
date_str = self.date_var.get().strip()
time_str = self.time_var.get().strip()
repeat = self.repeat_var.get()
if not title:
messagebox.showwarning("入力不足", "内容を入力してください。")
return
if priority not in PRIORITY_VALUES:
messagebox.showwarning("入力不足", "優先度を選択してください。")
return
if repeat not in REPEAT_OPTIONS:
messagebox.showwarning("入力不足", "繰り返しを選択してください。")
return
try:
datetime.strptime(date_str, DATE_FMT)
datetime.strptime(time_str, TIME_FMT)
except Exception:
messagebox.showwarning(
"入力エラー",
f"日付または時刻の形式が正しくありません。\n"
f"日付: YYYY/MM/DD 例 {date.today().strftime(DATE_FMT)} / "
f"時刻: HH:MM 例 {now_local().strftime(TIME_FMT)}"
)
return
self.on_ok(title, priority, date_str, time_str, repeat)
self.top.destroy()
def _cancel(self):
self.top.destroy()
# ========== エントリポイント ==========
def _windows_dpi_awareness():
try:
if sys.platform.startswith("win"):
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
def main():
try:
_windows_dpi_awareness()
root = tk.Tk()
apply_global_font_scaling(base_pt=16, heading_delta=3)
app = TodoApp(root)
if "--minimized" in sys.argv or "-m" in sys.argv:
root.update_idletasks()
root.iconify()
root.mainloop()
except Exception:
traceback.print_exc()
try:
messagebox.showerror("起動エラー", "アプリ起動中にエラーが発生しました。コンソールのトレースバックをご確認ください。")
except Exception:
pass
if __name__ == "__main__":
main()
AIユーザさんのコメント
(コメントID: 8709)
イトケンさん、リマインダー付きTODO作成アプリの開発、お疲れ様です!非常に多機能で、実用的なアプリに仕上がっていますね。特に、ポップアップの表示やショートカットキーの実装など、ユーザーの利便性を考慮した設計が素晴らしいです。
このアプリを職場で配布するためには、以下の点を考慮すると良いかもしれません。
1. **ユーザーインターフェースの改善**:
- 見た目や使いやすさをさらに向上させるために、UIのデザインを見直すことも検討してみてください。たとえば、テーマの変更やアイコンの追加などです。
2. **エラーハンドリング**:
- アプリが予期しないエラーで停止しないように、エラーハンドリングを強化することをお勧めします。特に、ユーザー入力に対するバリデーションを強化すると良いでしょう。
3. **パッケージ化と配布**:
- 職場での配布を考えると、Pythonスクリプトを実行可能なファイル(.exeファイルなど)に変換することが便利です。PyInstallerやcx_Freezeなどのツールを使用すると、簡単にパッケージ化できます。
4. **ドキュメントの整備**:
- 使用方法やインストール手順を記載したドキュメントを用意しておくと、他のユーザーにとっても親切です。
もしさらに具体的なアドバイスや質問があれば、ぜひお知らせください。応援しています!