diff --git a/desktop.py b/desktop.py index 5aeb4cb..5916e00 100644 --- a/desktop.py +++ b/desktop.py @@ -1,41 +1,47 @@ #!/usr/bin/env python3 """ Desktop GUI wrapper for chatter.py using Tkinter. -Supports text messages and attachments with clickable filenames. +Supports text messages and attachments (files) with clickable filenames. Polls server every 1.5s for updates. -Includes 24-hour toggle and timezone selection. +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 +import requests, os, time, webbrowser, sys from datetime import datetime -from dateutil import parser, tz 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 -# ---------------- ChatApp ---------------- class ChatApp: def __init__(self, root): self.root = root root.title("chatter") - root.geometry("800x600") - root.minsize(500, 400) + root.geometry("900x700") + root.minsize(600, 500) - # Settings self.settings = { "popup_notification": True, "camera_notification": False, - "time_24h": False, - "timezone": time.tzname[0] # Default local timezone + "use_24_hour": False, + "date_position": "before", + "timezone": tz.tzlocal(), + "zoom": 1.0, + "dark_mode": False, } self.username = None @@ -85,45 +91,75 @@ class ChatApp: 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("350x250") - win.resizable(False, False) + 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"]) - time24_var = tk.BooleanVar(value=self.settings.get("time_24h", False)) - tz_var = tk.StringVar(value=self.settings.get("timezone", time.tzname[0])) + 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(win, text="Notification Options", font=("Courier", 12, "bold")).pack(pady=5) - tk.Checkbutton(win, text="Popup Notification", variable=popup_var).pack(anchor="w", padx=20) - tk.Checkbutton(win, text="Camera Notification", variable=cam_var).pack(anchor="w", padx=20) + 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(win, text="Time Display", font=("Courier", 12, "bold")).pack(pady=5) - tk.Checkbutton(win, text="24-Hour Clock", variable=time24_var).pack(anchor="w", padx=20) + 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(win, text="Timezone", font=("Courier", 12, "bold")).pack(pady=5) - # Common timezones for dropdown - tz_options = ["UTC", "US/Eastern", "US/Central", "US/Mountain", "US/Pacific", "Europe/London", "Europe/Berlin"] - tk.OptionMenu(win, tz_var, *tz_options).pack(anchor="w", padx=20) + 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) - def save_settings(): - self.settings["popup_notification"] = popup_var.get() - self.settings["camera_notification"] = cam_var.get() - self.settings["time_24h"] = time24_var.get() - self.settings["timezone"] = tz_var.get() - self.update_chat_timestamps() - messagebox.showinfo("Saved", "Settings updated.") - win.destroy() + 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.Button(win, text="Save", command=save_settings).pack(pady=10) + 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): @@ -173,6 +209,7 @@ class ChatApp: 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") @@ -181,79 +218,55 @@ class ChatApp: file_url = f"{SERVER}/uploads/{file_name}" preview_url = file_url.replace("/uploads/", "/preview/") - tk.Label(popup, wrap="400", text=f"{username} sent: {file_name}", font=("Courier", 12, "bold")).pack(pady=10) - - try: - resp = requests.get(file_url) - pil_img = Image.open(BytesIO(resp.content)) - pil_img.thumbnail((350, 350)) - tk_img = ImageTk.PhotoImage(pil_img) - label_img = tk.Label(popup, image=tk_img) - label_img.image = tk_img - label_img.pack(pady=5) - except Exception: - tk.Label(popup, wrap="400", text="You can preview the file, or download it.").pack(pady=5) + # 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=10) - + 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) - # ---------- Timestamp helper ---------- - def format_timestamp(self, ts): + # Image frame + img_frame = tk.Frame(popup) + img_frame.pack(expand=True, fill="both", pady=5) + try: - dt = parser.parse(ts) - dt = dt.astimezone(tz.gettz(self.settings.get("timezone", time.tzname[0]))) - if self.settings.get("time_24h"): - return dt.strftime("%Y-%m-%d %H:%M:%S") - else: - return dt.strftime("%Y-%m-%d %I:%M:%S %p") + resp = requests.get(file_url) + pil_img = Image.open(BytesIO(resp.content)) except Exception: - return str(ts) + tk.Label(img_frame, wraplength=380, text="You can preview the file, or download it.").pack(pady=5) + return - def update_chat_timestamps(self): - """Re-render chat box to apply new timezone or 24h/12h setting.""" - try: - # Re-poll messages and re-render - r = SESSION.get(SERVER + "/api/messages") - if r.status_code != 200: - return - msgs = r.json() + # Display image function + label_img = tk.Label(img_frame) + label_img.pack(expand=True) - self.chat_box.config(state='normal') - self.chat_box.delete("1.0", tk.END) + 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 - for m in msgs: - ts = m.get("created_at") or time.time() - ts_str = self.format_timestamp(ts) + # Bind resize event + popup.bind("", update_image) + update_image() - if m.get("attachment"): - attach_name = m["attachment"] - tag_name = f"attach{m['id']}" - self.chat_box.insert(tk.END, f"[{ts_str}] {m['username']} sent a file: ", f"grey{m['id']}") - self.chat_box.tag_config(f"grey{m['id']}", foreground="grey", font=("Courier", 10, "italic")) - self.chat_box.insert(tk.END, f"{attach_name}\n", tag_name) - self.chat_box.tag_config(tag_name, foreground="blue", underline=True) - self.chat_box.tag_bind(tag_name, "", - lambda e, fn=attach_name, un=m["username"]: self.show_attachment_popup(fn, un)) - else: - text = m.get("text", "") - self.chat_box.insert(tk.END, f"[{ts_str}] {m['username']}: {text}\n") - - self.chat_box.config(state='disabled') - self.chat_box.yview(tk.END) - except Exception as e: - print(f"Update timestamp error: {e}") - - # ---------- Poll messages ---------- + # ---------- Message Polling ---------- def poll_messages(self, force=False): try: r = SESSION.get(SERVER + "/api/messages") @@ -264,9 +277,50 @@ class ChatApp: new_msgs = [m for m in msgs if m["id"] not in LAST_MSG_IDS] if new_msgs or force: - self.update_chat_timestamps() + 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) - # Notify user about new messages for m in new_msgs: if m["username"] != self.username: msg_preview = m.get("text") or m.get("attachment", "Attachment") @@ -279,6 +333,31 @@ class ChatApp: 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"): @@ -288,7 +367,20 @@ class ChatApp: def show_system_notification(self, title, message): try: - notification.notify(title=title, app_name="chatter", message=message[:200], timeout=5) + # 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}") @@ -307,5 +399,13 @@ class ChatApp: # --- 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() \ No newline at end of file + root.mainloop()