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)