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

イトケンです。
今回はリマインダー付き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