opencode-chat-exporter 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chat_exporter/gui.py ADDED
@@ -0,0 +1,546 @@
1
+ """OpenCode Chat Exporter GUI - a friendly desktop interface.
2
+
3
+ Browse every OpenCode conversation from the local database and export it
4
+ into one continuous, verbatim Markdown transcript - including every command,
5
+ search, file operation and message, exactly as it happened.
6
+ """
7
+ import base64
8
+ import json
9
+ import os
10
+ import queue
11
+ import re
12
+ import threading
13
+ import time
14
+ import tkinter as tk
15
+ from pathlib import Path
16
+ from tkinter import filedialog, messagebox
17
+
18
+ import customtkinter as ctk
19
+
20
+ from .db import DEFAULT_DB, make_db
21
+ from .render import Exporter
22
+
23
+ CONFIG_DIR = Path(os.getenv("APPDATA") or str(Path.home())) / "OpenCodeChatExporter"
24
+ CONFIG_FILE = CONFIG_DIR / "paths.json"
25
+
26
+ ctk.set_appearance_mode("dark")
27
+
28
+ BG = "#0e0e12"
29
+ PANEL = "#17171c"
30
+ SURFACE = "#1e1e26"
31
+ LINE = "#2b2b34"
32
+ ACCENT = "#7aa2ff"
33
+ GREEN = "#86d0a0"
34
+ RED = "#e07080"
35
+ MUTED = "#8b8b99"
36
+
37
+ FONT = "Segoe UI"
38
+
39
+
40
+ class ChatViewer(tk.Text):
41
+ """Renders the conversation as colored chat blocks using a fast tag-based Text."""
42
+
43
+ def __init__(self, master, **kw):
44
+ self._container = ctk.CTkFrame(master, fg_color="#101014", corner_radius=8, border_width=1,
45
+ border_color="#2b2b34")
46
+ self._container.grid_columnconfigure(0, weight=1)
47
+ self._container.grid_rowconfigure(0, weight=1)
48
+ kw.setdefault("bg", "#101014")
49
+ kw.setdefault("fg", "#e8e8f0")
50
+ kw.setdefault("insertbackground", "#e8e8f0")
51
+ kw.setdefault("wrap", "word")
52
+ kw.setdefault("padx", 14)
53
+ kw.setdefault("pady", 12)
54
+ kw.setdefault("borderwidth", 0)
55
+ kw.setdefault("highlightthickness", 0)
56
+ kw.setdefault("state", "disabled")
57
+ kw.setdefault("selectbackground", "#2a4a80")
58
+ super().__init__(self._container, **kw)
59
+ self.grid(row=0, column=0, sticky="nsew")
60
+ vsb = ctk.CTkScrollbar(self._container, command=self.yview)
61
+ vsb.grid(row=0, column=1, sticky="ns", padx=(0, 6), pady=6)
62
+ self.configure(yscrollcommand=vsb.set)
63
+
64
+ self.tag_config("user", background="#232f46", foreground="#cfe4ff")
65
+ self.tag_config("user_head", background="#232f46", foreground="#7aa2ff",
66
+ font=(FONT, 12, "bold"))
67
+ self.tag_config("assistant", background="#1c2820", foreground="#dceee0")
68
+ self.tag_config("asst_head", background="#1c2820", foreground="#86d0a0",
69
+ font=(FONT, 12, "bold"))
70
+ self.tag_config("tool", background="#241d2e", foreground="#d6c8ec",
71
+ font=("Consolas", 11))
72
+ self.tag_config("meta", background="#15151a", foreground="#8b8b99",
73
+ font=(FONT, 10, "italic"))
74
+ self.tag_config("error", background="#3a1e22", foreground="#ffb3bd")
75
+
76
+ def clear(self):
77
+ self.configure(state="normal")
78
+ self.delete("1.0", "end")
79
+ self.configure(state="disabled")
80
+
81
+ @property
82
+ def container(self):
83
+ return self._container
84
+
85
+ def add(self, kind: str, text: str):
86
+ if not text:
87
+ return
88
+ kind = kind if kind in ("user", "user_head", "asst_head", "tool", "meta", "error") else "asst"
89
+ self.configure(state="normal")
90
+ self.insert("end", text + "\n\n", kind)
91
+ self.configure(state="disabled")
92
+
93
+
94
+ class App(ctk.CTk):
95
+ def __init__(self):
96
+ super().__init__()
97
+ self.title("OpenCode Chat Exporter")
98
+ self.geometry("1280x860")
99
+ self.minsize(1024, 700)
100
+ self.configure(fg_color=BG)
101
+
102
+ self.db = None
103
+ self.sessions: list[dict] = []
104
+ self.current: dict | None = None
105
+ self.source = "opencode"
106
+ self._paths: dict[str, str] = self._load_paths()
107
+ self.watch_job = None
108
+ self._preview_q: queue.Queue = queue.Queue()
109
+
110
+ self._build_ui()
111
+ self._apply_path()
112
+ self._load_db()
113
+ self.after(200, self.refresh)
114
+ self.after(120, self._drain_preview)
115
+
116
+ # ---------------------------------------------------------------- ui build
117
+ def _build_ui(self):
118
+ self.grid_columnconfigure(1, weight=1)
119
+ self.grid_rowconfigure(1, weight=1)
120
+
121
+ # ---------------- header ----------------
122
+ header = ctk.CTkFrame(self, fg_color=PANEL, corner_radius=0, height=64)
123
+ header.grid(row=0, column=0, columnspan=2, sticky="ew")
124
+ header.grid_columnconfigure(2, weight=1)
125
+
126
+ ctk.CTkLabel(header, text="đź’¬ Chat Exporter", font=(FONT, 17, "bold"),
127
+ text_color="#ffffff").grid(row=0, column=0, padx=(16, 8), pady=14)
128
+
129
+ self.status = ctk.CTkLabel(header, text="loading…", text_color=MUTED, font=(FONT, 12))
130
+ self.status.grid(row=0, column=2, sticky="e", padx=16)
131
+
132
+ # ---------------- sidebar ----------------
133
+ self.sidebar = ctk.CTkFrame(self, fg_color=PANEL, corner_radius=0, width=320)
134
+ self.sidebar.grid(row=1, column=0, sticky="nsw", padx=(0, 1))
135
+ self.sidebar.grid_propagate(False)
136
+ self.sidebar.grid_columnconfigure(0, weight=1)
137
+
138
+ # db path row
139
+ seg = ctk.CTkSegmentedButton(self.sidebar, values=["OpenCode", "AntiGravity"],
140
+ command=self._source_changed, font=(FONT, 12))
141
+ seg.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 6))
142
+ seg.set("OpenCode")
143
+
144
+ row = ctk.CTkFrame(self.sidebar, fg_color="transparent")
145
+ row.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 4))
146
+ row.grid_columnconfigure(0, weight=1)
147
+ self.db_entry = ctk.CTkEntry(row, height=30, font=(FONT, 11))
148
+ self.db_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6))
149
+ self.db_entry.insert(0, str(DEFAULT_DB))
150
+ ctk.CTkButton(row, text="…", width=30, command=self._browse_db).grid(row=0, column=1)
151
+ ctk.CTkButton(row, text="âźł", width=34, command=self.refresh).grid(row=0, column=2, padx=(6, 0))
152
+
153
+ # search
154
+ self.search_entry = ctk.CTkEntry(self.sidebar, placeholder_text="🔎 search sessions…",
155
+ height=32, font=(FONT, 12))
156
+ self.search_entry.grid(row=2, column=0, sticky="ew", padx=10, pady=6)
157
+ self.search_entry.bind("<KeyRelease>", lambda e: self._filter_sessions())
158
+
159
+ # session list
160
+ self.list_frame = ctk.CTkScrollableFrame(self.sidebar, fg_color="transparent")
161
+ self.list_frame.grid(row=3, column=0, sticky="nsew", padx=6, pady=2)
162
+ self.sidebar.grid_rowconfigure(3, weight=1)
163
+
164
+ # options
165
+ opts = ctk.CTkFrame(self.sidebar, fg_color=SURFACE, corner_radius=10)
166
+ opts.grid(row=4, column=0, sticky="ew", padx=10, pady=(8, 10))
167
+ opts.grid_columnconfigure(0, weight=1)
168
+
169
+ self.ck_reasoning = ctk.CTkCheckBox(opts, text="Include reasoning", font=(FONT, 11))
170
+ self.ck_reasoning.grid(row=0, column=0, sticky="w", padx=8, pady=(8, 2))
171
+
172
+ self.ck_watch = ctk.CTkCheckBox(opts, text="Live watch (auto re-export)", font=(FONT, 11),
173
+ command=self._toggle_watch)
174
+ self.ck_watch.grid(row=1, column=0, sticky="w", padx=8, pady=(2, 2))
175
+
176
+ export_row = ctk.CTkFrame(opts, fg_color="transparent")
177
+ export_row.grid(row=2, column=0, sticky="ew", padx=8, pady=(6, 8))
178
+ export_row.grid_columnconfigure(0, weight=1)
179
+ export_row.grid_columnconfigure(1, weight=1)
180
+ ctk.CTkButton(export_row, text="đź“„ Export session", command=self._export_one,
181
+ fg_color=ACCENT, text_color="#0d1117", font=(FONT, 12, "bold")).grid(
182
+ row=0, column=0, sticky="ew", padx=(0, 4))
183
+ ctk.CTkButton(export_row, text="đź—‚ Export all", command=self._export_all,
184
+ fg_color=ACCENT, text_color="#0d1117", font=(FONT, 12, "bold")).grid(
185
+ row=0, column=1, sticky="ew", padx=(4, 0))
186
+
187
+ # ---------------- main preview ----------------
188
+ self.main = ctk.CTkFrame(self, fg_color=BG, corner_radius=0)
189
+ self.main.grid(row=1, column=1, sticky="nsew")
190
+ self.main.grid_columnconfigure(0, weight=1)
191
+ self.main.grid_rowconfigure(1, weight=1)
192
+
193
+ self.title_label = ctk.CTkLabel(self.main, text="Select a session…", font=(FONT, 16, "bold"),
194
+ text_color="#ffffff", anchor="w")
195
+ self.title_label.grid(row=0, column=0, sticky="ew", padx=18, pady=(14, 2))
196
+
197
+ self.meta_label = ctk.CTkLabel(self.main, text="", text_color=MUTED, font=(FONT, 11), anchor="w")
198
+ self.meta_label.grid(row=1, column=0, sticky="n", padx=18, pady=(0, 4))
199
+ self.main.grid_rowconfigure(1, weight=0)
200
+
201
+ self.viewer = ChatViewer(self.main)
202
+ self.viewer.container.grid(row=2, column=0, sticky="nsew", padx=14, pady=(4, 14))
203
+ self.main.grid_rowconfigure(2, weight=1)
204
+
205
+ # ---------------------------------------------------------------- database
206
+ def _load_paths(self) -> dict[str, str]:
207
+ try:
208
+ with open(CONFIG_FILE, encoding="utf-8") as f:
209
+ data = json.load(f)
210
+ return {k: str(v) for k, v in data.items() if k in ("opencode", "antigravity") and v}
211
+ except Exception:
212
+ return {}
213
+
214
+ def _save_paths(self):
215
+ try:
216
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
217
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
218
+ json.dump(self._paths, f, ensure_ascii=False, indent=2)
219
+ except Exception:
220
+ pass
221
+
222
+ def _apply_path(self):
223
+ p = self._paths.get(self.source)
224
+ if not p:
225
+ if self.source == "antigravity":
226
+ from .antigravity import DEFAULT_AG_DIR
227
+ p = str(DEFAULT_AG_DIR)
228
+ else:
229
+ p = str(DEFAULT_DB)
230
+ self.db_entry.delete(0, "end")
231
+ self.db_entry.insert(0, p)
232
+
233
+ def _source_changed(self, value: str):
234
+ new = value.lower()
235
+ if new != self.source:
236
+ self._paths[self.source] = self.db_entry.get().strip()
237
+ self._save_paths()
238
+ self.source = new
239
+ self._apply_path()
240
+ self.refresh()
241
+
242
+ def _browse_db(self):
243
+ if self.source == "antigravity":
244
+ p = filedialog.askdirectory(
245
+ title="Select the AntiGravity folder (contains conversations\\ and brain\\)",
246
+ initialdir=self.db_entry.get().strip(),
247
+ )
248
+ else:
249
+ p = filedialog.askopenfilename(
250
+ title="Select opencode.db",
251
+ initialdir=Path(self.db_entry.get()).parent,
252
+ filetypes=[("SQLite database", "*.db"), ("All files", "*.*")],
253
+ )
254
+ if p:
255
+ self.db_entry.delete(0, "end")
256
+ self.db_entry.insert(0, p)
257
+ self._paths[self.source] = p
258
+ self._save_paths()
259
+ self.refresh()
260
+
261
+ def _load_db(self) -> bool:
262
+ path = self.db_entry.get().strip()
263
+ try:
264
+ self.db = make_db(self.source, path)
265
+ return True
266
+ except Exception as e:
267
+ self.db = None
268
+ self._status(f"âš  DB error: {e}", RED)
269
+ return False
270
+
271
+ def refresh(self):
272
+ if not self._load_db():
273
+ self.viewer.clear()
274
+ return
275
+ try:
276
+ self.sessions = self.db.list_sessions()
277
+ except Exception as e:
278
+ self._status(f"âš  {e}", RED)
279
+ return
280
+ self._filter_sessions()
281
+ self._status(f"âś“ {len(self.sessions)} sessions", GREEN)
282
+
283
+ def _filter_sessions(self):
284
+ q = self.search_entry.get().strip().lower()
285
+ for w in self.list_frame.winfo_children():
286
+ w.destroy()
287
+ shown = 0
288
+ for s in self.sessions:
289
+ title = (s["title"] or "").lower()
290
+ if q and q not in title and q not in s["id"].lower():
291
+ continue
292
+ self._session_button(s)
293
+ shown += 1
294
+ if not shown:
295
+ ctk.CTkLabel(self.list_frame, text="no matches", text_color=MUTED).pack(pady=20)
296
+
297
+ def _session_button(self, s: dict):
298
+ created = self.db.ts(s["time_created"])
299
+ created_s = created.strftime("%Y-%m-%d %H:%M") if created else ""
300
+ n = s["message_count"]
301
+ active = s["id"] == (self.current or {}).get("id")
302
+ row = ctk.CTkFrame(
303
+ self.list_frame,
304
+ fg_color=ACCENT if active else SURFACE,
305
+ corner_radius=8,
306
+ height=52,
307
+ cursor="hand2",
308
+ )
309
+ row.pack(fill="x", padx=2, pady=2)
310
+ row.pack_propagate(False)
311
+ label = ctk.CTkLabel(
312
+ row,
313
+ text=f"{s['title'] or '(untitled)'}\n{created_s} · {n} messages",
314
+ text_color="#0d1117" if active else "#d9d9e3",
315
+ font=(FONT, 12),
316
+ justify="left",
317
+ anchor="w",
318
+ )
319
+ label.pack(fill="both", expand=True, padx=10, pady=4)
320
+ for w in (row, label):
321
+ w.bind("<Button-1>", lambda e, sid=s["id"]: self._select(sid))
322
+ w.bind("<Enter>", lambda e, r=row, a=active: r.configure(fg_color=ACCENT if a else "#2a2a35"))
323
+ w.bind("<Leave>", lambda e, r=row, a=active: r.configure(fg_color=ACCENT if a else SURFACE))
324
+
325
+ # ------------------------------------------------------------------ select
326
+ def _select(self, session_id: str):
327
+ if self.db is None:
328
+ return
329
+ s = next((x for x in self.sessions if x["id"] == session_id), None)
330
+ if not s:
331
+ return
332
+ self.current = s
333
+ self.title_label.configure(text=s["title"] or "(untitled)")
334
+ meta = []
335
+ if self.source == "antigravity":
336
+ meta.append("source: AntiGravity")
337
+ agent = s.get("agent")
338
+ if agent and agent != "antigravity":
339
+ meta.append(f"agent: {agent}")
340
+ model = s.get("model")
341
+ if isinstance(model, dict) and model.get("modelID"):
342
+ meta.append(f"model: {model['modelID']}")
343
+ created = self.db.ts(s["time_created"])
344
+ if created:
345
+ meta.append(created.strftime("%Y-%m-%d %H:%M"))
346
+ tokens = int(s.get("tokens_input") or 0) + int(s.get("tokens_output") or 0)
347
+ meta.append(f"{tokens:,} tokens" if tokens else "- tokens")
348
+ if s.get("directory"):
349
+ meta.append(str(s["directory"]))
350
+ self.meta_label.configure(text=" · ".join(meta))
351
+ self._filter_sessions()
352
+ self._status(f"loading {s['title']}…", MUTED)
353
+ threading.Thread(target=self._load_preview, args=(s["id"],), daemon=True).start()
354
+
355
+ def _drain_preview(self):
356
+ try:
357
+ while True:
358
+ item = self._preview_q.get_nowait()
359
+ if isinstance(item, tuple) and len(item) == 2 and item[0] == "__popup__":
360
+ title, msg, is_err = item[1]
361
+ if is_err:
362
+ messagebox.showerror(title, msg)
363
+ else:
364
+ messagebox.showinfo(title, msg)
365
+ else:
366
+ self._show_rows(item)
367
+ except queue.Empty:
368
+ pass
369
+ self.after(120, self._drain_preview)
370
+
371
+ def _load_preview(self, session_id: str):
372
+ try:
373
+ messages = self.db.get_session_messages(session_id)
374
+ rows = []
375
+ for m in messages:
376
+ role = m["data"].get("role")
377
+ if role == "user":
378
+ rows.append(("user_head", f"🟢 YOU · {self._fmt(m['time_created'])}"))
379
+ for p in m["parts"]:
380
+ t = p["data"].get("text")
381
+ if p["data"].get("type") == "text" and t:
382
+ rows.append(("user", t))
383
+ elif role == "assistant":
384
+ rows.append(("asst_head", f"🔵 OpenCode · {self._fmt(m['time_created'])}"))
385
+ for p in m["parts"]:
386
+ d = p["data"]
387
+ kind = d.get("type")
388
+ if kind == "text" and d.get("text"):
389
+ rows.append(("assistant", d["text"]))
390
+ elif kind == "tool":
391
+ rows.append(("tool", self._tool_block(d)))
392
+ elif kind == "reasoning" and self.ck_reasoning.get():
393
+ rows.append(("meta", "đź§  reasoning"))
394
+ rows.append(("meta", str(d.get("text", ""))[:4000]))
395
+ elif kind == "file":
396
+ rows.append(("tool", f"📎 {d.get('filename')} ({d.get('mime','')})"))
397
+ if not rows:
398
+ rows = [("meta", "(no messages)")]
399
+ self._preview_q.put(rows)
400
+ except Exception as e:
401
+ self._preview_q.put([("error", f"failed to load: {e}")])
402
+
403
+ def _show_rows(self, rows):
404
+ self.viewer.clear()
405
+ for kind, text in rows:
406
+ self.viewer.add(kind, text)
407
+ self.viewer.see("1.0")
408
+ if self.current:
409
+ self._status(f"âś“ {len(rows)} blocks", GREEN)
410
+
411
+ @staticmethod
412
+ def _fmt(ms):
413
+ try:
414
+ import datetime
415
+ return datetime.datetime.fromtimestamp(ms / 1000).strftime("%H:%M:%S")
416
+ except Exception:
417
+ return ""
418
+
419
+ def _tool_block(self, d: dict) -> str:
420
+ tool = d.get("tool") or d.get("name") or "tool"
421
+ state = d.get("state") or {}
422
+ status = state.get("status", "")
423
+ icon = {"completed": "✅", "error": "⛔", "cancelled": "🚫"}.get(status, "⏳")
424
+ lines = [f"{icon} {tool} · {status}"]
425
+ inp = state.get("input") or {}
426
+ title = ""
427
+ for k in ("command", "pattern", "filePath", "path", "url", "query"):
428
+ if inp.get(k) is not None:
429
+ title = str(inp[k])
430
+ break
431
+ if title:
432
+ lines.append(f" in: {title}")
433
+ elif inp:
434
+ lines.append(" in: " + json.dumps(inp, ensure_ascii=False)[:600])
435
+ out = state.get("output")
436
+ if isinstance(out, str) and out.startswith("data:"):
437
+ try:
438
+ out = base64.b64decode(out.split(",", 1)[1]).decode("utf-8", errors="replace")
439
+ except Exception:
440
+ out = "(attachment)"
441
+ if isinstance(out, (dict, list)):
442
+ out = json.dumps(out, ensure_ascii=False)
443
+ if out:
444
+ out = str(out)
445
+ out = out[:4000] + " …" if len(out) > 4000 else out
446
+ lines.append("")
447
+ lines.append(out)
448
+ return "\n".join(lines)
449
+
450
+ # ------------------------------------------------------------------- export
451
+ def _make_exporter(self) -> Exporter:
452
+ return Exporter(
453
+ make_db(self.source, self.db_entry.get().strip()),
454
+ include_reasoning=bool(self.ck_reasoning.get()),
455
+ include_metadata=False,
456
+ output_dir=None,
457
+ max_output=None,
458
+ )
459
+
460
+ def _export_one(self):
461
+ if not self.current:
462
+ messagebox.showinfo("Chat Exporter", "Select a session first.")
463
+ return
464
+ s = self.current
465
+ default_name = re.sub(r'[\\/:*?"<>|]', "_", s["title"] or s["id"]) + ".md"
466
+ dest = filedialog.asksaveasfilename(
467
+ title="Export session as Markdown",
468
+ defaultextension=".md",
469
+ initialfile=default_name,
470
+ filetypes=[("Markdown", "*.md"), ("Text", "*.txt"), ("All files", "*.*")],
471
+ )
472
+ if not dest:
473
+ return
474
+ self._status("exporting…", MUTED)
475
+ threading.Thread(target=self._do_export, args=(dest, [s["id"]]), daemon=True).start()
476
+
477
+ def _export_all(self):
478
+ folder = filedialog.askdirectory(title="Choose folder to export all sessions into")
479
+ if not folder:
480
+ return
481
+ ids = [s["id"] for s in self.sessions]
482
+ self._status(f"exporting {len(ids)} sessions…", MUTED)
483
+ threading.Thread(target=self._do_export, args=(Path(folder), ids), daemon=True).start()
484
+
485
+ def _do_export(self, dest, ids):
486
+ try:
487
+ exporter = self._make_exporter()
488
+ n = 0
489
+ if isinstance(dest, Path):
490
+ dest.mkdir(parents=True, exist_ok=True)
491
+ for sid in ids:
492
+ s = next((x for x in self.sessions if x["id"] == sid), {})
493
+ name = re.sub(r'[\\/:*?"<>|]', "_", s.get("title") or sid) + ".md"
494
+ (dest / name).write_text(exporter.render_session(sid), encoding="utf-8")
495
+ n += 1
496
+ self._preview_q.put([("meta", f"✓ exported {n} sessions → {dest}")])
497
+ self._preview_q.put(("__popup__", ("Done", f"Exported {n} sessions to:\n{dest}", False)))
498
+ else:
499
+ dest = Path(dest)
500
+ dest.write_text(exporter.render_session(ids[0]), encoding="utf-8")
501
+ self._preview_q.put([("meta", f"✓ exported → {dest}")])
502
+ except Exception as e:
503
+ self._preview_q.put([("error", f"âś— export failed: {e}")])
504
+ self._preview_q.put(("__popup__", ("Export failed", str(e), True)))
505
+
506
+ # --------------------------------------------------------------------- misc
507
+ def _toggle_watch(self):
508
+ if self.ck_watch.get():
509
+ self._start_watch()
510
+ else:
511
+ self._stop_watch()
512
+
513
+ def _start_watch(self):
514
+ self._stop_watch()
515
+ self._watch_loop()
516
+
517
+ def _stop_watch(self):
518
+ if self.watch_job:
519
+ self.after_cancel(self.watch_job)
520
+ self.watch_job = None
521
+
522
+ def _watch_loop(self):
523
+ if not self.ck_watch.get():
524
+ return
525
+ try:
526
+ self.refresh()
527
+ except Exception:
528
+ pass
529
+ self.watch_job = self.after(8000, self._watch_loop)
530
+
531
+ def _status(self, text: str, color: str = MUTED):
532
+ self.status.configure(text=text, text_color=color)
533
+
534
+ def on_close(self):
535
+ self._stop_watch()
536
+ self.destroy()
537
+
538
+
539
+ def main():
540
+ app = App()
541
+ app.protocol("WM_DELETE_WINDOW", app.on_close)
542
+ app.mainloop()
543
+
544
+
545
+ if __name__ == "__main__":
546
+ main()