投稿/コメントを表示します。

イトケンです。
今回はリマインダー付き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()
2025/09/01 22:01