2025-05-25 17:51:29 +00:00

323 lines
11 KiB
Python

from contextlib import suppress
from functools import partial
from collections import deque
import pystray
from pystray import Menu, MenuItem
from PIL import Image, ImageDraw
import sv_ttk
from pathlib import Path
import threading
import tkinter as tk
from tkinter import ttk, filedialog, scrolledtext
import config
from discord_subscriber import DiscordSubscriber
from monitor import Monitor, Event, SubscriberABC, Filter
from loguru import logger
from typing import Callable
class Feed(SubscriberABC):
def __init__(
self, event_filter: Filter, callback_data_changed: Callable | None = None
):
super().__init__(event_filter)
self.ring_buffer: deque[Event] = deque(maxlen=512)
self.ring_buffer_lock = threading.Lock()
self.callback = callback_data_changed
self.trace_callback = lambda name, b, c: self._notify_data_changed()
self.include_ts: tk.BooleanVar = config.db.get_tk_boolean("INCLUDE_TS", True)
self.include_ts.trace_add("write", self.trace_callback)
def consume_event(self, event: Event) -> None:
with self.ring_buffer_lock:
self.ring_buffer.append(event)
self._notify_data_changed()
def render(self) -> str:
with self.ring_buffer_lock:
return "\n".join(
event.to_str(include_ts=self.include_ts.get(), rich_text=False)
for event in self.ring_buffer
if self.filter.filter_event(event)
)
def clear(self) -> None:
with self.ring_buffer_lock:
self.ring_buffer.clear()
self._notify_data_changed()
def _notify_data_changed(self) -> None:
if callable(self.callback):
self.callback()
def get_image(
width: int = 64, height: int = 64, color1: str = "black", color2: str = "green"
) -> Image:
image = Image.new("RGB", (width, height), color1)
dc = ImageDraw.Draw(image)
dc.rectangle((width // 2, 0, width, height // 2), fill=color2)
dc.rectangle((0, height // 2, width // 2, height), fill=color2)
return image
class DeathLogGUI:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title(config.APP_NAME)
self.root.geometry(config.db.get_str("GEOMETRY", "850x600"))
self.root.minsize(850, 600)
self.root.protocol("WM_DELETE_WINDOW", self.handle_close_window)
theme_mapping = {True: "dark", False: "light"}
sv_ttk.set_theme(theme_mapping.get(config.db.get_bool("IS_DARK_MODE", False)))
self.log_path = config.db.get_tk_str(
"LOG_FILE_PATH", r"E:\RSI\StarCitizen\LIVE\Game.log"
)
self.do_backread = config.db.get_tk_boolean("DO_BACKREAD", False)
self.include_npc_kills = config.db.get_tk_boolean("INCLUDE_NPC_KILLS", True)
self.personal_feed = config.db.get_tk_boolean("PERSONAL_FEED", False)
self.filter = Filter(
only_personal_feed=self.personal_feed.get(),
include_npc=self.include_npc_kills.get(),
)
def filter_changed(a, b, c) -> None:
self.filter.include_npc = self.include_npc_kills.get()
self.filter.only_personal_feed = self.personal_feed.get()
logger.debug("Filter props changed")
self.feed.callback()
self.include_npc_kills.trace_add("write", filter_changed)
self.personal_feed.trace_add("write", filter_changed)
self.is_dark_mode = config.db.get_tk_boolean("IS_DARK_MODE", False)
self.is_dark_mode.trace_add(
"write",
lambda a, b, c: sv_ttk.set_theme(
theme_mapping.get(self.is_dark_mode.get()), self.root
),
)
self.feed = Feed(
callback_data_changed=partial(self.root.after, 0, self.update_feed_display),
event_filter=self.filter,
)
self.enable_discord = config.db.get_tk_boolean("ENABLE_DISCORD", False)
self.webhook_url = config.db.get_tk_str(
"DISCORD_WEBHOOK_URL", "https://discord.com/..."
)
self.discord = DiscordSubscriber(event_filter=self.filter)
self.discord.enabled = self.enable_discord.get()
self.discord.webhook_url = self.webhook_url.get()
self.webhook_url.trace_add(
"write", lambda a, b, c: self.discord.set_url(self.webhook_url.get())
)
self.enable_discord.trace_add(
"write", lambda a, b, c: self.discord.set_enabled(self.enable_discord.get())
)
self.monitor: Monitor | None = None
self.monitor_thread: threading.Thread | None = None
self.create_widgets()
self.update_feed_display()
self.tray = pystray.Icon(
name=config.APP_NAME,
icon=get_image(),
menu=Menu(
MenuItem("Show window", self.handle_open_window, default=True),
MenuItem("Exit", self.shutdown),
),
)
self.tray.visible = False
self.tray_thread = threading.Thread(
target=self.tray.run, kwargs={"setup": lambda _: None}
)
self.tray_thread.start()
def shutdown(self):
logger.info("Beginning GUI shutdown")
self.stop_monitoring()
logger.info(self.root.winfo_geometry())
config.db.set_str("GEOMETRY", self.root.winfo_geometry())
self.tray.stop()
config.db.db.close()
self.root.quit()
def handle_close_window(self) -> None:
self.root.withdraw()
self.tray.visible = True
def handle_open_window(self) -> None:
self.root.deiconify()
self.tray.visible = False
def create_widgets(self):
# Create main frame
main_frame = ttk.Frame(self.root, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
# Configuration section
config_frame = ttk.LabelFrame(main_frame, text="Configuration", padding="10")
config_frame.pack(fill=tk.X, pady=5)
# Log file path
ttk.Label(config_frame, text="Log File Path:").grid(
row=0, column=0, sticky=tk.W, pady=2
)
ttk.Entry(config_frame, textvariable=self.log_path, width=50).grid(
row=0, column=1, sticky=tk.W + tk.E, pady=2, padx=5
)
ttk.Button(config_frame, text="Browse", command=self.browse_log_file).grid(
row=0, column=2, pady=2
)
# Discord webhook URL
ttk.Label(config_frame, text="Discord Webhook URL:").grid(
row=1, column=0, sticky=tk.W, pady=2
)
ttk.Entry(config_frame, textvariable=self.webhook_url, width=50).grid(
row=1, column=1, sticky=tk.W + tk.E, pady=2, padx=5
)
# Checkboxes
ttk.Checkbutton(
config_frame, text="Process existing log entries", variable=self.do_backread
).grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=2)
ttk.Checkbutton(
config_frame,
text="Do send events to discord",
variable=self.enable_discord,
).grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=2)
ttk.Checkbutton(
config_frame,
text="Only personal events",
variable=self.personal_feed,
).grid(row=4, column=0, columnspan=2, sticky=tk.W, pady=2)
ttk.Checkbutton(
config_frame,
text="Hide NPC kills (_NPC_ in name)",
variable=self.include_npc_kills,
).grid(row=5, column=0, columnspan=2, sticky=tk.W, pady=2)
ttk.Checkbutton(
config_frame,
text="Include timestamps",
variable=self.feed.include_ts,
).grid(row=6, column=0, sticky=tk.W, pady=2)
ttk.Checkbutton(
config_frame,
text="Enable dark mode",
variable=self.is_dark_mode,
).grid(row=7, column=0, sticky=tk.W, pady=2)
# Control buttons
control_frame = ttk.Frame(main_frame)
control_frame.pack(fill=tk.X, pady=5)
self.start_button = ttk.Button(
control_frame, text="Start Monitoring", command=self.start_monitoring
)
self.start_button.pack(side=tk.LEFT, padx=5)
self.stop_button = ttk.Button(
control_frame,
text="Stop Monitoring",
command=self.stop_monitoring,
state=tk.DISABLED,
)
self.stop_button.pack(side=tk.LEFT, padx=5)
# Status indicator
self.status_var = tk.StringVar(value="Not monitoring")
status_frame = ttk.Frame(main_frame)
status_frame.pack(fill=tk.X, pady=5)
ttk.Label(status_frame, text="Status:").pack(side=tk.LEFT)
self.status_label = ttk.Label(
status_frame, textvariable=self.status_var, foreground="red"
)
self.status_label.pack(side=tk.LEFT, padx=5)
kills_frame = ttk.LabelFrame(
main_frame, text="Recent Kill Events", padding="10"
)
kills_frame.pack(fill=tk.BOTH, expand=True, pady=5)
self.feed_display = scrolledtext.ScrolledText(
kills_frame, wrap=tk.WORD, height=10
)
self.feed_display.pack(fill=tk.BOTH, expand=True)
self.feed_display.config(state=tk.DISABLED)
def browse_log_file(self):
filename = filedialog.askopenfilename(
title="Select Star Citizen Log File",
filetypes=[("Log Files", "*.log"), ("All Files", "*.*")],
)
if filename:
self.log_path.set(filename)
def start_monitoring(self):
if self.monitor_thread and self.monitor_thread.is_alive():
return
try:
self.monitor = Monitor(
log_file_path=Path(self.log_path.get()),
do_backread=self.do_backread.get(),
subscribers={self.feed, self.discord},
)
self.monitor_thread = threading.Thread(
target=self.monitor.process_lines, daemon=True
)
self.monitor_thread.start()
self.feed.clear()
self.status_var.set("Monitoring active")
self.status_label.config(foreground="green")
self.start_button.config(state=tk.DISABLED)
self.stop_button.config(state=tk.NORMAL)
except Exception as e:
self.status_var.set(f"Error: {str(e)}")
self.status_label.config(foreground="red")
logger.opt(exception=True).exception("Failed to start monitoring")
def stop_monitoring(self):
if self.monitor:
self.monitor.running = False
self.monitor_thread.join(timeout=1.0)
self.status_var.set("Monitoring stopped")
self.status_label.config(foreground="red")
self.start_button.config(state=tk.NORMAL)
self.stop_button.config(state=tk.DISABLED)
def update_feed_display(self):
with suppress(AttributeError):
self.filter.nickname = self.monitor.nickname
self.feed_display.config(state=tk.NORMAL)
self.feed_display.delete("1.0", tk.END)
self.feed_display.insert("1.0", self.feed.render())
self.feed_display.config(state=tk.DISABLED)
self.feed_display.see(tk.END)