パソコン仕事5倍塾
35,000人を指導した東大卒・元日本IBM社内講師が直伝
MENU
イトケンさんの投稿
(投稿ID: 5661)
# 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)
renpapa4407さんのコメント
(コメントID: 8710)
(コメントID: 8711)
小川 慶一さんのコメント
(コメントID: 8719)
(コメントID: 8720)
Captcha:
たった1ヶ月で人生が劇的に変わりました
佐藤信さん
役員クラスの方から高い評価を受けるようになりました。
佐賀県 - 岩本徹さん
今までまったく知らなかったショートカットキーを使いまくっています。
東京都 - 鷹觜慶さん
年齢60才間近、営業で外回りの時間が多い私でも退社時間が1-2時間程度早くなりました。
東京都 - 宗内隆明さん
受講前もすでにパソコン得意でしたが、そんな私でも受講して本当に良かったと思えた講座です。
東京都 - 佐伯とも子さん
ここまで包括的に仕事に役立つ深い学びを受けれた講座は初めてでした
東京都 - 飯田倫子さん
2025年09月25日 08:55
2025年09月20日 22:59
2025年09月12日 09:04
2025年09月12日 08:17
2025年09月08日 07:53
2025年09月27日 11:01
2025年09月27日 11:00
2025年09月25日 09:37
2025年09月25日 08:57
2025年09月21日 10:33
2022年02月24日 10:59
表示できる投稿はありません。
この学習サイトの教材制作、サポート、システム開発をすべてやっています。
2022年02月02日 00:00
2022年02月02日 03:00
2022年02月02日 06:00
2022年02月02日 09:00
2022年02月02日 12:00
2025年09月30日 17:00
2025年08月19日 15:30
2025年08月07日 15:00
2025年07月17日 17:30
2025年07月10日 11:30
2025年06月02日 22:50
2025年05月14日 11:55
2025年02月04日 08:51
2025年02月03日 12:58
2024年11月27日 11:48
2025年03月26日 22:33
2025年03月26日 22:12
2025年03月24日 22:28
2025年03月19日 20:10
2025年03月18日 20:52
2025年04月16日 08:30
2025年03月18日 09:07
2025年03月18日 09:06
2025年03月15日 13:23
2025年02月26日 16:37
2025年04月02日 21:01
2025年04月02日 20:47
2025年04月02日 20:37
2025年04月01日 22:03
2025年04月01日 21:27
2025年04月16日 08:32
2025年03月18日 09:10
2025年03月18日 09:09
イトケンさんの投稿
(投稿ID: 5661)
今回はリマインダー付きTODO作成アプリを作ってみました。
TODOメモ(リマインド機能付き)
・TODOを書き込む、ポップアップする締切時間を登録する
・時間になると、書き込んだTODOがポップアップする
・日次、週次、月次の繰り返し処理も登録できる
・ショートカットキーが使える
新規(ctrl+N)
編集(ctrl+E)
こういうアプリを職場で配布できるようになりたいですね。
AIユーザさんのコメント
(コメントID: 8709)
イトケンさん、リマインダー付きTODO作成アプリの開発、お疲れ様です!非常に多機能で、実用的なアプリに仕上がっていますね。特に、ポップアップの表示やショートカットキーの実装など、ユーザーの利便性を考慮した設計が素晴らしいです。
このアプリを職場で配布するためには、以下の点を考慮すると良いかもしれません。
1. **ユーザーインターフェースの改善**:
- 見た目や使いやすさをさらに向上させるために、UIのデザインを見直すことも検討してみてください。たとえば、テーマの変更やアイコンの追加などです。
2. **エラーハンドリング**:
- アプリが予期しないエラーで停止しないように、エラーハンドリングを強化することをお勧めします。特に、ユーザー入力に対するバリデーションを強化すると良いでしょう。
3. **パッケージ化と配布**:
- 職場での配布を考えると、Pythonスクリプトを実行可能なファイル(.exeファイルなど)に変換することが便利です。PyInstallerやcx_Freezeなどのツールを使用すると、簡単にパッケージ化できます。
4. **ドキュメントの整備**:
- 使用方法やインストール手順を記載したドキュメントを用意しておくと、他のユーザーにとっても親切です。
もしさらに具体的なアドバイスや質問があれば、ぜひお知らせください。応援しています!
renpapa4407さんのコメント
(コメントID: 8710)
pythonから独立して、PC上で常駐させておき時間になったらTODOをポップアップするようにしたいです。
これは実行可能なファイルにすることで実現できるのでしょうか。
今後のゼミが待ち遠しいです。
AIユーザさんのコメント
(コメントID: 8711)
イトケンさん、Pythonアプリを独立した実行可能なファイルにすることで、PC上で常駐させることが可能です。これにより、Pycharmを起動せずにアプリを実行できます。以下の方法で実現できます。
### 実行可能ファイルの作成
1. **PyInstallerの利用**:
- PyInstallerは、Pythonスクリプトを実行可能なファイルに変換するためのツールです。以下の手順で使用できます。
1. PyInstallerをインストールします。
```
pip install pyinstaller
```
2. コマンドラインで、あなたのPythonスクリプトを指定して実行します。
```
pyinstaller --onefile --windowed todo_reminder.py
```
- `--onefile`オプションは、すべての依存関係を1つの実行可能ファイルにまとめます。
- `--windowed`オプションは、コンソールウィンドウを表示しない設定です(GUIアプリの場合)。
2. **常駐アプリとしての設定**:
- 作成した実行可能ファイルをスタートアップに登録することで、PC起動時に自動でアプリが常駐するように設定できます。
- Windowsの場合、`shell:startup`コマンドを実行してスタートアップフォルダを開き、そこに実行可能ファイルのショートカットを配置します。
### 注意点
- **テスト**: 実行可能ファイルを作成した後、異なる環境でテストして、動作に問題がないか確認してください。
- **依存ライブラリ**: 一部の外部ライブラリが正しくパッケージ化されないことがあります。エラーメッセージを確認し、必要に応じて設定を調整してください。
この方法で、Pythonから独立した常駐アプリとして動作させることができます。次回のゼミでさらに詳しくお話しできるのを楽しみにしています!
小川 慶一さんのコメント
(コメントID: 8719)
試しに、手元の TKinter で作ったアプリを pyinstaller で .exe 化して shell:startup にショートカットを置いてみてください。
まずは、タイマーで一定時間ごとに popup するものを作ってみるくらいのところからですかね。
小川 慶一さんのコメント
(コメントID: 8720)
楽しみつつ作り込んだりいろいろ作ったりしていってください! (^^