r2chat/r2chat.py

457 lines
17 KiB
Python

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("<Key>", lambda e: "break")
self.entry.bind("<Return>", 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("<Double-1>", 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()