#!/usr/bin/env python3 """ Desktop GUI wrapper for chatter.py using Tkinter. Supports text messages and attachments (files) with clickable filenames. Polls server every 1.5s for updates. Settings now apply instantly. Attachment preview images resize dynamically with the popup. App icon set to chatter.png / chatter.ico for notifications. """ import threading import tkinter as tk from tkinter import filedialog, scrolledtext, messagebox import requests, os, time, webbrowser, sys from datetime import datetime from plyer import notification import cv2 from PIL import Image, ImageTk from io import BytesIO from dateutil import tz SERVER = "https://chatter.wholeworldcoding.com" SESSION = requests.Session() LAST_MSG_IDS = set() # Default font sizes BASE_FONT_SIZE = 10 BASE_PADDING = 5 class ChatApp: def __init__(self, root): self.root = root root.title("chatter") root.geometry("900x700") root.minsize(600, 500) self.settings = { "popup_notification": True, "camera_notification": False, "use_24_hour": False, "date_position": "before", "timezone": tz.tzlocal(), "zoom": 1.0, "dark_mode": False, } self.username = None self.build_login_screen() # ---------- Screens ---------- def build_login_screen(self): for w in self.root.winfo_children(): w.destroy() frame = tk.Frame(self.root) frame.pack(expand=True) tk.Label(frame, text="chatter", font=("Courier", 22, "bold")).pack(pady=10) tk.Label(frame, text="Sign up or login to continue.", font=("Courier", 11)).pack(pady=5) tk.Label(frame, text="Username").pack() self.entry_user = tk.Entry(frame, width=30) self.entry_user.pack(pady=3) tk.Label(frame, text="Password").pack() self.entry_pass = tk.Entry(frame, show="*", width=30) self.entry_pass.pack(pady=3) tk.Button(frame, text="Login", command=self.login, width=15).pack(pady=6) tk.Button(frame, text="Register", command=self.register, width=15).pack(pady=2) def build_chat_screen(self): for w in self.root.winfo_children(): w.destroy() self.root.rowconfigure(1, weight=1) self.root.columnconfigure(0, weight=1) header = tk.Frame(self.root) header.pack(fill=tk.X, pady=3) tk.Label(header, text=f"chatter - {self.username}", font=("Courier", 12)).pack(side=tk.LEFT, padx=10) tk.Button(header, text="Settings ⚙", command=self.open_settings).pack(side=tk.RIGHT, padx=5) tk.Button(header, text="Logout", command=self.logout).pack(side=tk.RIGHT, padx=5) self.chat_box = scrolledtext.ScrolledText(self.root, wrap=tk.WORD, state='disabled') self.chat_box.pack(padx=10, pady=5, fill=tk.BOTH, expand=True) frame = tk.Frame(self.root) frame.pack(fill=tk.X, pady=5) frame.columnconfigure(0, weight=1) self.msg_entry = tk.Entry(frame) self.msg_entry.grid(row=0, column=0, sticky="ew", padx=5) self.msg_entry.bind("", lambda e: self.send_message()) tk.Button(frame, text="Send", command=self.send_message).grid(row=0, column=1, padx=5) tk.Button(frame, text="📎 File", command=self.send_file).grid(row=0, column=2, padx=5) self.apply_theme() self.poll_messages() # ---------- Settings ---------- def open_settings(self): win = tk.Toplevel(self.root) win.title("Settings") win.geometry("400x400") canvas = tk.Canvas(win) scrollbar = tk.Scrollbar(win, orient="vertical", command=canvas.yview) scroll_frame = tk.Frame(canvas) scroll_frame.bind( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=scroll_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") # Enable mouse wheel scrolling def _on_mousewheel(event): canvas.yview_scroll(-int(event.delta/120), "units") scroll_frame.bind_all("", _on_mousewheel) # Variables popup_var = tk.BooleanVar(value=self.settings["popup_notification"]) cam_var = tk.BooleanVar(value=self.settings["camera_notification"]) hour24_var = tk.BooleanVar(value=self.settings.get("use_24_hour", False)) datepos_var = tk.StringVar(value=self.settings.get("date_position", "before")) zoom_var = tk.DoubleVar(value=self.settings.get("zoom", 1.0)) dark_var = tk.BooleanVar(value=self.settings.get("dark_mode", False)) tk.Label(scroll_frame, text="Notification Options", font=("Courier", 12, "bold")).pack(pady=10) tk.Checkbutton(scroll_frame, text="Popup Notification", variable=popup_var, command=lambda: self.update_setting("popup_notification", popup_var.get())).pack(anchor="w", padx=20, pady=5) tk.Checkbutton(scroll_frame, text="Camera Notification", variable=cam_var, command=lambda: self.update_setting("camera_notification", cam_var.get())).pack(anchor="w", padx=20, pady=5) tk.Label(scroll_frame, text="Clock Options", font=("Courier", 12, "bold")).pack(pady=10) tk.Checkbutton(scroll_frame, text="Use 24-hour time", variable=hour24_var, command=lambda: self.update_setting("use_24_hour", hour24_var.get())).pack(anchor="w", padx=20, pady=5) tk.Label(scroll_frame, text="Date Position", font=("Courier", 12, "bold")).pack(pady=10) tk.Radiobutton(scroll_frame, text="Before username", variable=datepos_var, value="before", command=lambda: self.update_setting("date_position", datepos_var.get())).pack(anchor="w", padx=20) tk.Radiobutton(scroll_frame, text="After username", variable=datepos_var, value="after", command=lambda: self.update_setting("date_position", datepos_var.get())).pack(anchor="w", padx=20) tk.Label(scroll_frame, text="Zoom (Font Size)", font=("Courier", 12, "bold")).pack(pady=10) tk.Scale(scroll_frame, variable=zoom_var, from_=0.5, to=3.0, resolution=0.1, orient="horizontal", command=lambda e: self.update_setting("zoom", zoom_var.get())).pack(padx=20, pady=5) tk.Label(scroll_frame, text="Appearance", font=("Courier", 12, "bold")).pack(pady=10) tk.Checkbutton(scroll_frame, text="Dark Mode", variable=dark_var, command=lambda: self.update_setting("dark_mode", dark_var.get())).pack(anchor="w", padx=20, pady=5) # ---------- Settings updater ---------- def update_setting(self, key, value): self.settings[key] = value if key in ["zoom", "dark_mode"]: self.apply_theme() self.poll_messages(force=True) # ---------- Auth ---------- def login(self): data = {"username": self.entry_user.get(), "password": self.entry_pass.get()} r = SESSION.post(SERVER + "/login", data=data) if r.url.endswith("/chat"): self.username = data["username"] self.build_chat_screen() else: messagebox.showerror("Error", "Login failed") def register(self): data = {"username": self.entry_user.get(), "password": self.entry_pass.get()} r = SESSION.post(SERVER + "/register", data=data) if r.url.endswith("/login"): messagebox.showinfo("Success", "Account created! You can log in now.") else: messagebox.showerror("Error", "Registration failed") def logout(self): SESSION.get(SERVER + "/logout") self.username = None self.build_login_screen() # ---------- Messaging ---------- def send_message(self): text = self.msg_entry.get().strip() if not text: return try: r = SESSION.post(SERVER + "/api/messages", json={"text": text}) if r.status_code == 201: self.msg_entry.delete(0, tk.END) self.poll_messages(force=True) except Exception as e: print(f"Send error: {e}") def send_file(self): path = filedialog.askopenfilename() if not path: return try: with open(path, "rb") as f: files = {"file": (os.path.basename(path), f)} SESSION.post(SERVER + "/api/messages", files=files) self.poll_messages(force=True) except Exception as e: print(f"File send error: {e}") # ---------- Attachment Preview ---------- def show_attachment_popup(self, file_name, username): popup = tk.Toplevel(self.root) popup.title(f"{username} sent a file") popup.geometry("400x400") file_url = f"{SERVER}/uploads/{file_name}" preview_url = file_url.replace("/uploads/", "/preview/") # Title title_label = tk.Label(popup, wraplength=380, text=f"{username} sent: {file_name}", font=self.get_font("italic"), fg="grey") title_label.pack(pady=5) # Buttons btn_frame = tk.Frame(popup) btn_frame.pack(pady=5) def download_file(): save_path = filedialog.asksaveasfilename(initialfile=file_name) if save_path: with open(save_path, "wb") as f: f.write(requests.get(file_url).content) messagebox.showinfo("Downloaded", f"File saved to {save_path}") tk.Button(btn_frame, text="Download", command=download_file).pack(side=tk.LEFT, padx=5) tk.Button(btn_frame, text="Preview", command=lambda: webbrowser.open(preview_url)).pack(side=tk.LEFT, padx=5) # Image frame img_frame = tk.Frame(popup) img_frame.pack(expand=True, fill="both", pady=5) try: resp = requests.get(file_url) pil_img = Image.open(BytesIO(resp.content)) except Exception: tk.Label(img_frame, wraplength=380, text="You can preview the file, or download it.").pack(pady=5) return # Display image function label_img = tk.Label(img_frame) label_img.pack(expand=True) def update_image(*args): popup.update_idletasks() max_width = popup.winfo_width() - 20 max_height = popup.winfo_height() - title_label.winfo_reqheight() - btn_frame.winfo_reqheight() - 40 img_copy = pil_img.copy() img_copy.thumbnail((max_width, max_height)) tk_img = ImageTk.PhotoImage(img_copy) label_img.config(image=tk_img) label_img.image = tk_img # Bind resize event popup.bind("", update_image) update_image() # ---------- Message Polling ---------- def poll_messages(self, force=False): try: r = SESSION.get(SERVER + "/api/messages") if r.status_code != 200: return msgs = r.json() new_ids = {m["id"] for m in msgs} new_msgs = [m for m in msgs if m["id"] not in LAST_MSG_IDS] if new_msgs or force: self.chat_box.config(state='normal') self.chat_box.delete("1.0", tk.END) padding = int(BASE_PADDING * self.settings.get("zoom", 1.0)) for m in msgs: ts = m.get("created_at") or time.time() try: dt = datetime.fromtimestamp(float(ts)).astimezone(self.settings["timezone"]) ts_str = dt.strftime("%m/%d/%Y, %H:%M:%S") if self.settings.get("use_24_hour") else dt.strftime("%m/%d/%Y, %I:%M:%S %p") except Exception: ts_str = str(ts) ts_tag = f"ts{m['id']}" uname_tag = f"bold{m['id']}" msg_tag = f"msg{m['id']}" attach_tag = f"attach{m['id']}" if m.get("attachment"): attach_name = m["attachment"] if self.settings["date_position"] == "before": self.chat_box.insert(tk.END, f"[{ts_str}] {m['username']} sent a file: ", ts_tag) else: self.chat_box.insert(tk.END, f"{m['username']} [{ts_str}] sent a file: ", ts_tag) self.chat_box.tag_config(ts_tag, foreground="grey", font=self.get_font("italic")) self.chat_box.insert(tk.END, f"{attach_name}\n", attach_tag) self.chat_box.tag_config(attach_tag, foreground="blue", font=self.get_font("normal"), underline=True) self.chat_box.tag_bind(attach_tag, "", lambda e, fn=attach_name, un=m["username"]: self.show_attachment_popup(fn, un)) else: if self.settings["date_position"] == "before": self.chat_box.insert(tk.END, f"[{ts_str}] ", ts_tag) self.chat_box.insert(tk.END, f"{m['username']}", uname_tag) self.chat_box.insert(tk.END, f": {m.get('text','')}\n", msg_tag) else: self.chat_box.insert(tk.END, f"{m['username']}", uname_tag) self.chat_box.insert(tk.END, f" [{ts_str}]: ", ts_tag) self.chat_box.insert(tk.END, f"{m.get('text','')}\n", msg_tag) self.chat_box.tag_config(ts_tag, foreground="grey", font=self.get_font("grey")) self.chat_box.tag_config(uname_tag, font=self.get_font("bold")) self.chat_box.tag_config(msg_tag, font=self.get_font("normal"), spacing1=padding, spacing3=padding) self.chat_box.config(state='disabled') self.chat_box.yview(tk.END) for m in new_msgs: if m["username"] != self.username: msg_preview = m.get("text") or m.get("attachment", "Attachment") self.notify_user(m["username"], msg_preview) LAST_MSG_IDS.clear() LAST_MSG_IDS.update(new_ids) except Exception as e: print(f"Poll error: {e}") self.root.after(1500, self.poll_messages) # ---------- Font helpers ---------- def get_font(self, kind="normal"): scale = self.settings.get("zoom", 1.0) size = max(int(BASE_FONT_SIZE * scale), 1) if kind == "bold": return ("Courier", size+1, "bold") elif kind == "italic": return ("Courier", size, "italic") elif kind == "grey": return ("Courier", max(size-1, 1)) else: return ("Courier", size) # ---------- Theme ---------- def apply_theme(self): dark = self.settings.get("dark_mode", False) bg = "#1e1e1e" if dark else "white" fg = "#d4d4d4" if dark else "black" entry_bg = "#2e2e2e" if dark else "white" entry_fg = "#d4d4d4" if dark else "black" self.chat_box.config(bg=bg, fg=fg, insertbackground=fg) self.msg_entry.config(bg=entry_bg, fg=entry_fg, insertbackground=fg) self.root.config(bg=bg) # ---------- Notifications ---------- def notify_user(self, sender, message): if self.settings.get("popup_notification"): threading.Thread(target=lambda: self.show_system_notification(sender, message), daemon=True).start() if self.settings.get("camera_notification"): threading.Thread(target=self.blink_camera_light, daemon=True).start() def show_system_notification(self, title, message): try: # Determine icon path for PyInstaller if getattr(sys, 'frozen', False): base_path = sys._MEIPASS else: base_path = os.path.dirname(os.path.abspath(__file__)) icon_path = os.path.join(base_path, "chatter.ico") notification.notify( title=title, message=message[:200], app_name="chatter", app_icon=icon_path, # Use .ico for Windows toast timeout=5 ) except Exception as e: print(f"Notification failed: {e}") def blink_camera_light(self, blink_time=3): try: cap = cv2.VideoCapture(0) if cap.isOpened(): ret, frame = cap.read() if ret: time.sleep(blink_time) cap.release() except Exception as e: print(f"Camera blink failed: {e}") # --- Run GUI --- if __name__ == "__main__": root = tk.Tk() # Set app icon try: icon_img = tk.PhotoImage(file="chatter.png") root.iconphoto(True, icon_img) except Exception as e: print(f"Failed to load icon: {e}") app = ChatApp(root) root.mainloop()