#!/usr/bin/env python3 """ event-tracker: minimal GTK3 app that logs button-press events to a CSV file. Main window: today's hour-by-hour counts per event. Stats popup: per-event totals, streaks, and hour/week/month trend charts. Settings popup: rename events, add new ones, and relocate the data file. Why CSV instead of sqlite: the log is small by nature (a handful of rows a day), and a plain file means the user can open it in a text editor or spreadsheet and fix a bad row by hand without needing any tooling. """ import csv import json import os import shutil from collections import Counter from datetime import datetime, timedelta import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib import matplotlib matplotlib.use("GTK3Agg") from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg as FigureCanvas from matplotlib.figure import Figure from matplotlib.ticker import MaxNLocator # ---- Configuration — everything environment/event-specific lives here ---- DEFAULT_EVENTS = ["Cigarette", "B-event"] # starting event list on first run only COLOR_PALETTE = ["#4c72b0", "#c44e52", "#55a868", "#8172b2", "#ccb974", "#64b5cd"] # cycles if more events than colors DEFAULT_DATA_PATH = os.path.expanduser("~/.local/share/event-tracker/event_log.csv") # used until changed in Settings CONFIG_PATH = os.path.expanduser("~/.local/share/event-tracker/config.json") # app settings — event list + data path WEEKLY_WEEKS = 8 # trailing weeks shown in the stats popup's weekly chart MONTHLY_MONTHS = 6 # trailing months shown in the stats popup's monthly chart TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S" # sortable, human-readable, DST-safe (local time) WEEKDAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] WINDOW_TITLE = "Event Tracker" WM_CLASS = "event-tracker" # must match the .desktop file's StartupWMClass ICON_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "event-tracker.png") def event_color(index): return COLOR_PALETTE[index % len(COLOR_PALETTE)] # ---- app settings persistence: event list + data file location ---- # Kept separate from the CSV on purpose — config.json is small app state # that always lives at CONFIG_PATH; the CSV is the actual tracked data, # and its location (data_path, inside this same file) is what moves. def load_config(): if os.path.exists(CONFIG_PATH): with open(CONFIG_PATH) as f: data = json.load(f) events = data.get("events") or list(DEFAULT_EVENTS) data_path = os.path.expanduser(data.get("data_path") or DEFAULT_DATA_PATH) return events, data_path return list(DEFAULT_EVENTS), DEFAULT_DATA_PATH def save_config(events, data_path): os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) with open(CONFIG_PATH, "w") as f: json.dump({"events": events, "data_path": data_path}, f, indent=2) def move_data_file(old_path, new_path): """Relocate the CSV to a new path. If a file already exists at the destination, back it up first (same convention as Reset) instead of silently overwriting whatever was already there.""" old_path, new_path = os.path.abspath(old_path), os.path.abspath(new_path) if old_path == new_path: return os.makedirs(os.path.dirname(new_path), exist_ok=True) if os.path.exists(new_path): backup_path = f"{new_path}.bak-{datetime.now().strftime('%Y%m%d-%H%M%S')}" shutil.copy2(new_path, backup_path) if os.path.exists(old_path): shutil.move(old_path, new_path) # ---- CSV log ---- def ensure_csv(csv_path, default_event_name): os.makedirs(os.path.dirname(csv_path), exist_ok=True) if not os.path.exists(csv_path): with open(csv_path, "w", newline="") as f: csv.writer(f).writerow(["event", "timestamp"]) return # migrate the original single-event format (header: ["timestamp"], no # event column) so existing logs aren't orphaned by the multi-event switch with open(csv_path, newline="") as f: rows = list(csv.reader(f)) if rows and rows[0] == ["timestamp"]: migrated = [["event", "timestamp"]] + [[default_event_name, r[0]] for r in rows[1:] if r] with open(csv_path, "w", newline="") as f: csv.writer(f).writerows(migrated) def load_entries(csv_path, default_event_name): ensure_csv(csv_path, default_event_name) entries = [] with open(csv_path, newline="") as f: reader = csv.reader(f) next(reader, None) # skip header for row in reader: if len(row) < 2: continue event, ts_raw = row[0], row[1] try: entries.append((event, datetime.strptime(ts_raw, TIMESTAMP_FORMAT))) except ValueError: continue # tolerate a hand-edited/malformed row instead of crashing return sorted(entries, key=lambda e: e[1]) def append_entry(csv_path, event, ts): with open(csv_path, "a", newline="") as f: csv.writer(f).writerow([event, ts.strftime(TIMESTAMP_FORMAT)]) def rename_event_in_csv(csv_path, old_name, new_name): if not os.path.exists(csv_path): return with open(csv_path, newline="") as f: rows = list(csv.reader(f)) if not rows: return header, data_rows = rows[0], rows[1:] changed = False for row in data_rows: if row and row[0] == old_name: row[0] = new_name changed = True if changed: with open(csv_path, "w", newline="") as f: writer = csv.writer(f) writer.writerow(header) writer.writerows(data_rows) def reset_log(csv_path): """Back up the current log with a timestamped copy, then start a fresh one. The backup means a mis-click doesn't actually destroy history.""" os.makedirs(os.path.dirname(csv_path), exist_ok=True) if os.path.exists(csv_path): backup_path = f"{csv_path}.bak-{datetime.now().strftime('%Y%m%d-%H%M%S')}" shutil.copy2(csv_path, backup_path) with open(csv_path, "w", newline="") as f: csv.writer(f).writerow(["event", "timestamp"]) # ---- stats ---- def compute_stats(stamps): """stamps: sorted list of datetimes for one event. Returns a summary dict, or None if there's nothing logged yet.""" if not stamps: return None now = datetime.now() total = len(stamps) span_hours = max((stamps[-1] - stamps[0]).total_seconds() / 3600, 1) span_days = max(span_hours / 24, 1) def count_since(cutoff): return sum(1 for ts in stamps if ts >= cutoff) last_7 = count_since(now - timedelta(days=7)) prev_7 = sum(1 for ts in stamps if now - timedelta(days=14) <= ts < now - timedelta(days=7)) last_30 = count_since(now - timedelta(days=30)) month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) week_start = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) dates = sorted(set(ts.date() for ts in stamps)) date_set = set(dates) longest_streak = run = 1 for i in range(1, len(dates)): run = run + 1 if (dates[i] - dates[i - 1]).days == 1 else 1 longest_streak = max(longest_streak, run) current_streak = 0 day = now.date() while day in date_set: current_streak += 1 day -= timedelta(days=1) hour_counts = Counter(ts.hour for ts in stamps) day_counts = Counter(ts.date() for ts in stamps) weekday_counts = Counter(ts.weekday() for ts in stamps) # 0 = Monday busiest_hour, busiest_hour_count = hour_counts.most_common(1)[0] busiest_day, busiest_day_count = max(day_counts.items(), key=lambda kv: kv[1]) trend_pct = (last_7 - prev_7) / prev_7 * 100 if prev_7 > 0 else None return { "total": total, "avg_per_hour": total / span_hours, "avg_per_day": total / span_days, "today": count_since(now.replace(hour=0, minute=0, second=0, microsecond=0)), "this_week": count_since(week_start), "this_month": count_since(month_start), "last_7_days": last_7, "last_7_days_avg": last_7 / 7, "prev_7_days": prev_7, "trend_pct": trend_pct, "last_30_days": last_30, "last_30_days_avg": last_30 / 30, "longest_streak": longest_streak, "current_streak": current_streak, "days_since_last": (now.date() - stamps[-1].date()).days, "busiest_hour": busiest_hour, "busiest_hour_count": busiest_hour_count, "busiest_day": busiest_day, "busiest_day_count": busiest_day_count, "first_logged": stamps[0], "last_logged": stamps[-1], "hour_counts": hour_counts, "weekday_counts": weekday_counts, } def weekly_counts(stamps, weeks): """Total events per calendar week (Mon-start) for the trailing `weeks` weeks, including the current partial week. Returns (labels, values).""" this_week_start = (datetime.now().date() - timedelta(days=datetime.now().weekday())) starts = [this_week_start - timedelta(weeks=i) for i in range(weeks - 1, -1, -1)] buckets = Counter(ts.date() - timedelta(days=ts.weekday()) for ts in stamps) values = [buckets.get(s, 0) for s in starts] labels = [s.strftime("%d %b") for s in starts] return labels, values def monthly_counts(stamps, months): """Total events per calendar month for the trailing `months` months, including the current partial month. Returns (labels, values).""" now = datetime.now() keys = [] y, m = now.year, now.month for i in range(months - 1, -1, -1): km, ky = m - i, y while km <= 0: km += 12 ky -= 1 keys.append((ky, km)) buckets = Counter((ts.year, ts.month) for ts in stamps) values = [buckets.get(k, 0) for k in keys] labels = [datetime(k[0], k[1], 1).strftime("%b %Y") for k in keys] return labels, values def format_elapsed(delta): """Elapsed time to the minute, e.g. '3d 2h 5m' or '45m'. Larger units are omitted when zero, but minutes always show (including '0m').""" total_minutes = max(int(delta.total_seconds() // 60), 0) days, remainder = divmod(total_minutes, 1440) hours, minutes = divmod(remainder, 60) parts = [] if days: parts.append(f"{days}d") if days or hours: parts.append(f"{hours}h") parts.append(f"{minutes}m") return " ".join(parts) def format_trend(pct): if pct is None: return "not enough data yet (need a prior 7-day window)" if pct > 0: return f"up {pct:.0f}% vs prior 7 days" if pct < 0: return f"down {abs(pct):.0f}% vs prior 7 days" return "flat vs prior 7 days" # ---- stats & trends popup ---- class StatsDialog(Gtk.Dialog): def __init__(self, parent, events, entries): super().__init__(title="Stats & Trends", transient_for=parent, flags=0) self.set_default_size(560, 620) self.add_button("Close", Gtk.ResponseType.CLOSE) notebook = Gtk.Notebook() content = self.get_content_area() content.set_border_width(8) content.pack_start(notebook, True, True, 0) for index, event in enumerate(events): stamps = sorted(ts for ev, ts in entries if ev == event) page = self.build_event_page(stamps, event_color(index)) notebook.append_page(page, Gtk.Label(label=event)) self.show_all() def build_event_page(self, stamps, color): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_border_width(10) stats = compute_stats(stamps) if stats is None: box.pack_start(Gtk.Label(label="No events logged yet."), False, False, 0) return box weekday_str = " ".join( f"{WEEKDAY_NAMES[d]}:{stats['weekday_counts'].get(d, 0)}" for d in range(7) ) rows = [ ("Total logged (all-time)", str(stats["total"])), ("Avg / hour (all-time)", f"{stats['avg_per_hour']:.2f}"), ("Avg / day (all-time)", f"{stats['avg_per_day']:.2f}"), ("Today", str(stats["today"])), ("This calendar week", str(stats["this_week"])), ("This calendar month", str(stats["this_month"])), ("Last 7 days", f"{stats['last_7_days']} total, {stats['last_7_days_avg']:.2f}/day"), ("Last 30 days", f"{stats['last_30_days']} total, {stats['last_30_days_avg']:.2f}/day"), ("Trend", format_trend(stats["trend_pct"])), ("Current streak", f"{stats['current_streak']} consecutive day(s)"), ("Longest streak", f"{stats['longest_streak']} consecutive day(s)"), ("Days since last event", str(stats["days_since_last"])), ("Busiest hour (all-time)", f"{stats['busiest_hour']:02d}:00 — {stats['busiest_hour_count']} events"), ("Busiest day (all-time)", f"{stats['busiest_day'].strftime('%d %b %Y')} — {stats['busiest_day_count']} events"), ("By weekday (all-time)", weekday_str), ("First logged", stats["first_logged"].strftime("%d %b %Y %H:%M")), ("Last logged", stats["last_logged"].strftime("%d %b %Y %H:%M")), ] grid = Gtk.Grid(column_spacing=16, row_spacing=4) for row, (label_text, value_text) in enumerate(rows): grid.attach(Gtk.Label(label=label_text, xalign=0), 0, row, 1, 1) grid.attach(Gtk.Label(label=value_text, xalign=0), 1, row, 1, 1) box.pack_start(grid, False, False, 0) # three angles on the same data: hour of day, week, month chart_tabs = Gtk.Notebook() chart_tabs.append_page( self.bar_chart(range(24), [stats["hour_counts"].get(h, 0) for h in range(24)], [f"{h:02d}:00" for h in range(0, 24, 3)], list(range(0, 24, 3)), "hour of day", color), Gtk.Label(label="By Hour"), ) week_labels, week_values = weekly_counts(stamps, WEEKLY_WEEKS) chart_tabs.append_page( self.bar_chart(range(len(week_labels)), week_values, week_labels, range(len(week_labels)), "week starting", color, rotate=45), Gtk.Label(label="By Week"), ) month_labels, month_values = monthly_counts(stamps, MONTHLY_MONTHS) chart_tabs.append_page( self.bar_chart(range(len(month_labels)), month_values, month_labels, range(len(month_labels)), "month", color, rotate=45), Gtk.Label(label="By Month"), ) box.pack_start(chart_tabs, True, True, 0) return box @staticmethod def bar_chart(x_positions, values, tick_labels, tick_positions, xlabel, color, rotate=0): figure = Figure(figsize=(4.8, 2.6)) axes = figure.add_subplot(111) axes.bar(list(x_positions), values, color=color) axes.set_xlabel(xlabel) axes.set_ylabel("events") axes.set_xticks(list(tick_positions)) axes.set_xticklabels(list(tick_labels), rotation=rotate, ha="right" if rotate else "center") axes.yaxis.set_major_locator(MaxNLocator(integer=True)) # event counts are whole numbers figure.tight_layout() return FigureCanvas(figure) # ---- settings popup ---- class SettingsDialog(Gtk.Dialog): def __init__(self, parent, events, data_path): super().__init__(title="Settings", transient_for=parent, flags=0) self.set_default_size(460, 380) self.add_button("Cancel", Gtk.ResponseType.CANCEL) self.add_button("Save", Gtk.ResponseType.OK) self.entries = [] content = self.get_content_area() content.set_border_width(10) content.pack_start( Gtk.Label(label="Rename an event by editing its text, or add a new one below.", xalign=0), False, False, 4, ) self.rows_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) content.pack_start(self.rows_box, True, True, 0) for name in events: self.add_row(name) add_button = Gtk.Button(label="+ Add Event") add_button.connect("clicked", lambda _w: self.add_row("")) content.pack_start(add_button, False, False, 6) content.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 4) content.pack_start(Gtk.Label(label="Data file location:", xalign=0), False, False, 0) path_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.path_entry = Gtk.Entry() self.path_entry.set_text(data_path) self.path_entry.set_hexpand(True) browse_button = Gtk.Button(label="Browse…") browse_button.connect("clicked", self.on_browse) path_row.pack_start(self.path_entry, True, True, 0) path_row.pack_start(browse_button, False, False, 0) content.pack_start(path_row, False, False, 0) note = Gtk.Label( label="Changing this moves the existing log there on Save " "(any file already at the destination is backed up first).", xalign=0, ) note.set_line_wrap(True) content.pack_start(note, False, False, 4) self.show_all() def add_row(self, name): row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) entry = Gtk.Entry() entry.set_text(name) entry.set_hexpand(True) row.pack_start(entry, True, True, 0) self.entries.append(entry) self.rows_box.pack_start(row, False, False, 0) row.show_all() def on_browse(self, _widget): chooser = Gtk.FileChooserDialog( title="Choose data file location", transient_for=self, action=Gtk.FileChooserAction.SAVE, ) chooser.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK, ) chooser.set_do_overwrite_confirmation(False) # we do our own backup-on-conflict below current = self.path_entry.get_text().strip() or DEFAULT_DATA_PATH current_dir = os.path.dirname(current) or os.path.expanduser("~") os.makedirs(current_dir, exist_ok=True) chooser.set_current_folder(current_dir) chooser.set_current_name(os.path.basename(current) or "event_log.csv") if chooser.run() == Gtk.ResponseType.OK: self.path_entry.set_text(chooser.get_filename()) chooser.destroy() def get_event_names(self): return [e.get_text().strip() for e in self.entries] def get_data_path(self): return os.path.expanduser(self.path_entry.get_text().strip()) class TrackerWindow(Gtk.Window): def __init__(self): super().__init__(title=WINDOW_TITLE) self.set_default_size(560, 640) self.set_border_width(12) self.events, self.data_path = load_config() root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.add(root) # -- record buttons (rebuilt whenever the event list changes) -- self.button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) root.pack_start(self.button_box, False, False, 0) # -- stats grid: one row per event, columns = total / avg-hour / avg-day -- self.stats_grid = Gtk.Grid(column_spacing=20, row_spacing=4) self.stats_grid.set_halign(Gtk.Align.CENTER) for col, header in enumerate(["Event", "Total", "Avg/hour", "Avg/day", "Last Event", "Time Since"]): self.stats_grid.attach(Gtk.Label(label=header), col, 0, 1, 1) self.stat_labels = {} self._event_row_widgets = [] root.pack_start(self.stats_grid, False, False, 0) # -- secondary actions -- action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) action_box.set_halign(Gtk.Align.CENTER) stats_button = Gtk.Button(label="View Stats & Trends") stats_button.connect("clicked", self.on_view_stats) action_box.pack_start(stats_button, False, False, 0) settings_button = Gtk.Button(label="Settings") settings_button.connect("clicked", self.on_settings) action_box.pack_start(settings_button, False, False, 0) reset_button = Gtk.Button(label="Reset Stats") reset_button.get_style_context().add_class("destructive-action") reset_button.connect("clicked", self.on_reset) action_box.pack_start(reset_button, False, False, 0) root.pack_start(action_box, False, False, 0) # -- today's graph -- self.figure = Figure(figsize=(5, 3.5)) self.axes = self.figure.add_subplot(111) self.canvas = FigureCanvas(self.figure) root.pack_start(self.canvas, True, True, 0) self.rebuild_event_widgets() self.refresh() # "Time Since" ticks forward even with no new events, so keep it # live rather than only updating on user action GLib.timeout_add_seconds(60, self.on_tick) def on_tick(self): self.refresh() return True # keep the timeout running def rebuild_event_widgets(self): for child in self.button_box.get_children(): child.destroy() for event in self.events: button = Gtk.Button(label=f"Record {event}") button.set_size_request(-1, 60) button.connect("clicked", self.on_record, event) self.button_box.pack_start(button, True, True, 0) self.button_box.show_all() for widget in self._event_row_widgets: widget.destroy() self._event_row_widgets = [] self.stat_labels = {} for row, event in enumerate(self.events, start=1): label = Gtk.Label(label=event) self.stats_grid.attach(label, 0, row, 1, 1) self._event_row_widgets.append(label) cells = [Gtk.Label(), Gtk.Label(), Gtk.Label(), Gtk.Label(), Gtk.Label()] for col, cell in enumerate(cells, start=1): self.stats_grid.attach(cell, col, row, 1, 1) self._event_row_widgets.append(cell) self.stat_labels[event] = cells self.stats_grid.show_all() def on_record(self, _widget, event): append_entry(self.data_path, event, datetime.now()) self.refresh() def on_view_stats(self, _widget): dialog = StatsDialog(self, self.events, load_entries(self.data_path, self.events[0])) dialog.run() dialog.destroy() def on_settings(self, _widget): dialog = SettingsDialog(self, self.events, self.data_path) while True: response = dialog.run() if response != Gtk.ResponseType.OK: break names = dialog.get_event_names() new_data_path = dialog.get_data_path() error = self.validate_settings(names, new_data_path) if error: self.show_message(Gtk.MessageType.ERROR, error) continue self.apply_settings(names, new_data_path) break dialog.destroy() self.rebuild_event_widgets() self.refresh() @staticmethod def validate_settings(names, data_path): if any(not n for n in names): return "Event names can't be empty." if len(set(names)) != len(names): return "Event names must be unique." if not data_path: return "Data file location can't be empty." return None def apply_settings(self, names, new_data_path): for old, new in zip(self.events, names[: len(self.events)]): if old != new: rename_event_in_csv(self.data_path, old, new) self.events = names if new_data_path != self.data_path: move_data_file(self.data_path, new_data_path) self.data_path = new_data_path save_config(self.events, self.data_path) def on_reset(self, _widget): dialog = Gtk.MessageDialog( transient_for=self, flags=0, message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.NONE, text="Reset all logged events?", ) dialog.format_secondary_text( "This clears every recorded timestamp for every event. " "A timestamped backup of the current log is saved alongside it " "first, but this cannot be undone from inside the app." ) dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) confirm_button = dialog.add_button("Reset", Gtk.ResponseType.OK) confirm_button.get_style_context().add_class("destructive-action") response = dialog.run() dialog.destroy() if response == Gtk.ResponseType.OK: reset_log(self.data_path) self.refresh() def show_message(self, message_type, text): dialog = Gtk.MessageDialog( transient_for=self, flags=0, message_type=message_type, buttons=Gtk.ButtonsType.OK, text=text, ) dialog.run() dialog.destroy() def refresh(self): entries = load_entries(self.data_path, self.events[0]) self.update_stats(entries) self.update_graph(entries) def update_stats(self, entries): for event in self.events: stamps = sorted(ts for ev, ts in entries if ev == event) total_cell, hour_cell, day_cell, last_cell, since_cell = self.stat_labels[event] total = len(stamps) if total == 0: total_cell.set_text("0") hour_cell.set_text("-") day_cell.set_text("-") last_cell.set_text("-") since_cell.set_text("-") continue # average rate = total events / time elapsed since the first # logged event, floored at 1h/1d so one press doesn't divide by ~0 span_hours = max((stamps[-1] - stamps[0]).total_seconds() / 3600, 1) span_days = max(span_hours / 24, 1) total_cell.set_text(str(total)) hour_cell.set_text(f"{total / span_hours:.2f}") day_cell.set_text(f"{total / span_days:.2f}") last_cell.set_text(stamps[-1].strftime("%d %b %H:%M")) since_cell.set_text(format_elapsed(datetime.now() - stamps[-1])) def update_graph(self, entries): self.axes.clear() today = datetime.now().date() hours = list(range(24)) for index, event in enumerate(self.events): counts = Counter(ts.hour for ev, ts in entries if ev == event and ts.date() == today) values = [counts.get(h, 0) for h in hours] self.axes.plot(hours, values, marker="o", label=event, color=event_color(index)) self.axes.set_ylabel("events") self.axes.set_xlabel("hour") self.axes.set_title(f"Today — {today.strftime('%d %b %Y')}") self.axes.set_xticks(range(0, 24, 2)) self.axes.set_xticklabels([f"{h:02d}:00" for h in range(0, 24, 2)], rotation=45, ha="right") self.axes.set_xlim(-0.5, 23.5) self.axes.yaxis.set_major_locator(MaxNLocator(integer=True)) # event counts are whole numbers self.axes.legend() self.figure.tight_layout() self.canvas.draw() def main(): # sets the window's WM_CLASS so KDE/GNOME taskbars match this window to # the .desktop entry's StartupWMClass instead of falling back to some # unrelated running app's icon GLib.set_prgname(WM_CLASS) if os.path.exists(ICON_FILE): Gtk.Window.set_default_icon_from_file(ICON_FILE) # applies to the main window and every dialog/popup win = TrackerWindow() win.connect("destroy", Gtk.main_quit) win.show_all() Gtk.main() if __name__ == "__main__": main()