forked from Dogo6647/r2chat
457 lines
17 KiB
Python
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()
|