import tkinter as tk from tkinter import scrolledtext, messagebox from tkinter import ttk, colorchooser, font as tkfont import os, sys, json from pathlib import Path import time import getpass from plyer import notification import pygame as media import webbrowser import re import subprocess import pathify if sys.platform == "win32": ROOT = Path("C:/Users/Public") else: ROOT = Path("/var/tmp") try: if sys.argv[1]!='chat': CHANCHAN = pathify.chn(sys.argv[1]) CHANNEL = 'chat_'+pathify.dir(CHANCHAN) else: CHANNEL = 'chat' CHANCHAN = 'chat' except: CHANNEL = 'chat' CHANCHAN = 'chat' CHANNELS_DIR = ROOT / "r2chat" CHAT_FILE = CHANNELS_DIR / f"{CHANNEL}.txt" INSTANCE_DIR = CHANNELS_DIR / "instance" os.makedirs(INSTANCE_DIR, exist_ok=True) CONFIG_FILE = Path.home() / "r2chat.config.json" os.makedirs(os.path.dirname(CHAT_FILE), exist_ok=True) REFRESH_INTERVAL = 1000 COLOR_PATTERN = re.compile(r"\(\((#[0-9a-fA-F]{6})\s*\|\s*([^)]+)\)\)") status = "online" username = getpass.getuser() USERFILE = INSTANCE_DIR / f"{username}.json" media.mixer.init() DEFAULT_CONFIG = { "font_family": "Lucida Console", "font_size": 11, "border_width": 2, "bg_color": "#f0f0f0", "fg_color": "#1e1e1e", "user_color": "#0000ff", "status_join": "{username} is now online.", "status_leave": "{username} is now offline.", "status_inactive": "{username} will be right back.", "status_busy": "{username} is busy." } def load_config(): if CONFIG_FILE.exists(): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: user_cfg = json.load(f) return {**DEFAULT_CONFIG, **user_cfg} # user values override defaults except Exception as e: print(f":o config load failed, using defaults instead. error: {e}") else: with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(DEFAULT_CONFIG, f, indent=2) print(f":) created default config at {CONFIG_FILE}") return DEFAULT_CONFIG.copy() class r2chat: def __init__(self, root): self.root = root self.cfg = load_config() self.root.title(f"r2chat :: {username} / #{CHANCHAN}") self.last_size = 0 # configs font = (self.cfg["font_family"], self.cfg["font_size"]) bg_color = self.cfg["bg_color"] fg_color = self.cfg["fg_color"] self.user_data = self.load_userdata() if CHANNEL not in self.user_data["channels"]: self.user_data["channels"].append(CHANNEL) self.user_data["status"] = status self.save_userdata(self.user_data) # menubar self.menubar = tk.Menu(root) self.root.config(menu=self.menubar) ## chat menu self.chatmenu = tk.Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="Chat", menu=self.chatmenu) self.chatmenu.add_command(label="Registered users", command=lambda: self.show_users()) self.chatmenu.add_command(label="Channel list", command=lambda: self.show_channels()) self.chatmenu.add_command(label="Leave channel", command=lambda: self.on_close()) ## insert menu self.chatmenu = tk.Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="Insert", menu=self.chatmenu) self.chatmenu.add_command(label="Emoticon", command=lambda: print("")) self.chatmenu.add_command(label="GIF", command=lambda: print("")) ## status menu self.chatmenu = tk.Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="Status", menu=self.chatmenu) self.chatmenu.add_command(label="Online (All notifs)", command=lambda: self.setstatus("online")) self.chatmenu.add_command(label="Inactive (Pings only)", command=lambda: self.setstatus("inactive")) self.chatmenu.add_command(label="Busy (No notifs)", command=lambda: self.setstatus("busy")) self.chatmenu.add_command(label="Appear offline", command=lambda: self.setstatus("offline")) ## menu buttons self.menubar.add_command(label="Preferences", command=lambda: self.openprefs()) self.menubar.add_command(label="Help", command=lambda: webbrowser.open_new_tab("https://forgejo.nishi.boats/Dogo6647/r2chat")) # MAIN MENU STUFFS self.entry = tk.Entry(root, relief='sunken', borderwidth=self.cfg["border_width"]) self.entry.pack(padx=10, pady=(0, 10), fill=tk.X, side=tk.BOTTOM) self.chat_area = scrolledtext.ScrolledText(root, wrap='word', font=font, bg=bg_color, fg=fg_color, relief='ridge', borderwidth=self.cfg["border_width"]) self.chat_area.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) self.chat_area.bind("", lambda e: "break") self.entry.bind("", self.send_message) if not os.path.exists(CHAT_FILE): open(CHAT_FILE, "a").close() self.update_chat() self.write_system_message(f'◉ {self.cfg["status_join"]}') def on_close(self): self.write_system_message(f'◌ {self.cfg["status_leave"]}') if CHANNEL in self.user_data["channels"]: self.user_data["channels"].remove(CHANNEL) if not self.user_data["channels"]: self.user_data["status"] = "offline" self.save_userdata(self.user_data) self.root.destroy() def write_system_message(self, msg): line = f"---- {msg.format(username=username)} ----\n" with open(CHAT_FILE, "a", encoding="utf-8") as f: f.write(line) def send_message(self, event=None): msg = self.entry.get().strip() if msg and msg.startswith("/join #"): channel = msg[7:len(msg)] self.open_channel(channel) msg = '' self.entry.delete(0, tk.END) if msg: timestamp = time.strftime("%y/%m/%d %H:%M") line = f"[{timestamp}] (({self.cfg['user_color']} | {username})): {msg}\n" try: with open(CHAT_FILE, "a", encoding="utf-8") as f: f.write(line) except Exception as e: print("oh noes! ", e) self.entry.delete(0, tk.END) def setstatus(self, new_status): global status if status == new_status: return status = new_status if status == "online": self.write_system_message(msg=f'((#00af00 | ◉)) {self.cfg["status_join"]}') elif status == "offline": self.write_system_message(msg=f'((#000000 | ◌)) {self.cfg["status_leave"]}') elif status == "inactive": self.write_system_message(msg=f'((#ffaf00 | ◍)) {self.cfg["status_inactive"]}') elif status == "busy": self.write_system_message(msg=f'((#af0000 | ◎)) {self.cfg["status_busy"]}') self.user_data["status"] = status self.save_userdata(self.user_data) def load_userdata(self): path = USERFILE if path.exists(): try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except: pass return { "username": username, "status": "online", "channels": [], "last_seen": time.time() } def save_userdata(self, data): data["last_seen"] = time.time() with open(USERFILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) def show_users(self): win = tk.Toplevel(self.root) win.title("Registered users") win.geometry("420x320") tree = ttk.Treeview(win, columns=("user", "status", "channels", "last_seen"), show="headings") tree.heading("user", text="User") tree.heading("status", text="Status") tree.heading("channels", text="Channels") tree.heading("last_seen", text="Last seen") tree.column("user", width=80) tree.column("status", width=60, anchor="center") tree.column("channels", width=140) tree.column("last_seen", width=120, anchor="e") tree.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) for file in INSTANCE_DIR.glob("*.json"): try: with open(file, "r", encoding="utf-8") as f: data = json.load(f) last_seen = time.strftime("%H:%M:%S", time.localtime(data.get("last_seen", 0))) channels = ", ".join(data.get("channels", [])) tree.insert("", tk.END, values=( file.stem, data.get("status", "?"), channels, last_seen )) except Exception as e: print(":( user read error:", e) def show_channels(self): win = tk.Toplevel(self.root) win.title("Channel list") win.geometry("600x320") tree = ttk.Treeview(win, columns=("channel"), show="headings") tree.heading("channel", text="Channels") tree.column("channel", width=100) tree.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) for file in CHANNELS_DIR.glob("*.txt"): try: tree.insert("", tk.END, values=( file.stem.replace("chat_", "") )) except Exception as e: print(":( instance read error:", e) def on_select(event): selected = tree.focus() if selected: channel = tree.item(selected)["values"][0] win.destroy() self.open_channel(channel) tree.bind("", on_select) def open_channel(self, channel): self.write_system_message(f"((#006fff | ⇄)) {username} is chatting in {channel}.") if sys.platform == "win32": subprocess.Popen(f'pythonw "{os.path.abspath(__file__)}" "{channel}"', shell=True) else: subprocess.Popen(f'python "{os.path.abspath(__file__)}" "{channel}"', shell=True) def openprefs(self): PreferencesWindow(self.root, self.cfg, self.save_prefs) def save_prefs(self, new_cfg): self.cfg = new_cfg with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(new_cfg, f, indent=2) font = (new_cfg["font_family"], new_cfg["font_size"]) self.chat_area.config(font=font, bg=new_cfg["bg_color"], fg=new_cfg["fg_color"], borderwidth=new_cfg["border_width"]) self.entry.config(borderwidth=new_cfg["border_width"]) def render_text(self, text): pos = 0 for match in COLOR_PATTERN.finditer(text): start, end = match.span() color = match.group(1) content = match.group(2) if start > pos: self.chat_area.insert(tk.END, text[pos:start]) tag_name = f"color_{color}" if not tag_name in self.chat_area.tag_names(): self.chat_area.tag_config(tag_name, foreground=color) self.chat_area.insert(tk.END, content, tag_name) pos = end if pos < len(text): self.chat_area.insert(tk.END, text[pos:]) def update_chat(self): try: current_size = os.path.getsize(CHAT_FILE) if current_size != self.last_size: with open(CHAT_FILE, "r", encoding="utf-8") as f: f.seek(self.last_size) new_data = f.read() self.last_size = current_size self.chat_area.config(state='normal') if f"@{username} " in new_data and not "---" in new_data and not status == "busy" and not status == "offline": media.mixer.Sound("assets/internal/ping.ogg").play() messagebox.showinfo("Important message!", new_data) if self.root.wm_state() == 'iconic': if not f"@{username} " in new_data and not status == "busy" and not status == "inactive": notification.notify(title="r2chat", message=new_data) media.mixer.Sound("assets/internal/notif.ogg").play() self.render_text(new_data) self.chat_area.config(state='disabled') self.chat_area.yview(tk.END) except Exception as e: print("oh noes! ", e) self.root.after(REFRESH_INTERVAL, self.update_chat) class PreferencesWindow: def __init__(self, parent, cfg, on_save): self.parent = parent self.cfg = cfg.copy() self.on_save = on_save self.win = tk.Toplevel(parent) self.win.title("r2chat prefs") self.win.geometry("420x520") self.win.resizable(False, False) self.win.transient(parent) self.win.grab_set() self.vars = {} self._build() def _build(self): notebook = ttk.Notebook(self.win) notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) self._build_appearance_tab(notebook) self._build_status_messages_tab(notebook) btn_frame = tk.Frame(self.win) btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) tk.Button(btn_frame, text="Save", width=10, command=self._save).pack(side=tk.RIGHT, padx=(4, 0)) tk.Button(btn_frame, text="Cancel", width=10, command=self.win.destroy).pack(side=tk.RIGHT) tk.Button(btn_frame, text="Back to defaults", command=self._reset).pack(side=tk.LEFT) def _build_appearance_tab(self, notebook): frame = tk.Frame(notebook) notebook.add(frame, text="Interface") # font family self._row(frame, 0, "Font family") font_var = tk.StringVar(value=self.cfg["font_family"]) self.vars["font_family"] = font_var families = sorted(tkfont.families()) font_combo = ttk.Combobox(frame, textvariable=font_var, values=families, state="readonly", width=24) font_combo.grid(row=0, column=1, padx=10, pady=6, sticky=tk.W) # font size self._row(frame, 1, "Font size") size_var = tk.IntVar(value=self.cfg["font_size"]) self.vars["font_size"] = size_var tk.Spinbox(frame, from_=7, to=24, textvariable=size_var, width=6).grid(row=1, column=1, padx=10, pady=6, sticky=tk.W) # border width self._row(frame, 2, "Frame thickness") bw_var = tk.IntVar(value=self.cfg["border_width"]) self.vars["border_width"] = bw_var tk.Spinbox(frame, from_=0, to=6, textvariable=bw_var, width=6).grid(row=2, column=1, padx=10, pady=6, sticky=tk.W) # color pickers for i, (key, label) in enumerate([ ("bg_color", "Background color"), ("fg_color", "Text color"), ("user_color", "Username color"), ], start=3): self._row(frame, i, label) self._color_picker_row(frame, i, key) def _build_status_messages_tab(self, notebook): frame = tk.Frame(notebook) notebook.add(frame, text="Status messages") tk.Label(frame, text="Use {username} where you want your username to be.", fg="gray", font=("Lucida Console", 9)).grid( row=0, column=0, columnspan=2, padx=10, pady=(10, 4), sticky=tk.W) for i, (key, label) in enumerate([("status_join", "Join message"),("status_leave", "Leave message"),("status_inactive", "Inactive message"),("status_busy", "Busy message")], start=1): self._row(frame, i, label) var = tk.StringVar(value=self.cfg[key]) self.vars[key] = var tk.Entry(frame, textvariable=var, width=32).grid(row=i, column=1, padx=10, pady=6, sticky=tk.W) def _row(self, frame, row, text): tk.Label(frame, text=text, anchor=tk.W, width=18).grid(row=row, column=0, padx=10, pady=6, sticky=tk.W) def _color_picker_row(self, frame, row, key): color_var = tk.StringVar(value=self.cfg[key]) self.vars[key] = color_var preview = tk.Label(frame, bg=self.cfg[key], width=4, relief="sunken", borderwidth=1) preview.grid(row=row, column=1, padx=(10, 4), pady=6, sticky=tk.W) def pick(v=color_var, p=preview, k=key): result = colorchooser.askcolor(color=v.get(), title=k) if result[1]: v.set(result[1]) p.config(bg=result[1]) tk.Button(frame, text="Choose…", command=pick, width=8).grid(row=row, column=1, padx=(44, 0), pady=6, sticky=tk.W) def _save(self): for key, var in self.vars.items(): self.cfg[key] = var.get() self.on_save(self.cfg) self.win.destroy() def _reset(self): from __main__ import DEFAULT_CONFIG for key, var in self.vars.items(): if key in DEFAULT_CONFIG: var.set(DEFAULT_CONFIG[key]) if __name__ == "__main__": root = tk.Tk() app = r2chat(root) root.geometry("500x520") root.minsize(360, 360) #root.attributes('-topmost', True) root.protocol("WM_DELETE_WINDOW", app.on_close) root.mainloop()