commit a96c164fee1ca4c17011c0f1d950dc0e5dbc8b22 Author: rattatwinko Date: Sat Nov 22 21:41:46 2025 +0100 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e15106e --- /dev/null +++ b/.gitignore @@ -0,0 +1,216 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/FiraCode-Regular.ttf b/FiraCode-Regular.ttf new file mode 100644 index 0000000..bd73685 Binary files /dev/null and b/FiraCode-Regular.ttf differ diff --git a/FiraCode-SemiBold.ttf b/FiraCode-SemiBold.ttf new file mode 100644 index 0000000..d8dcef6 Binary files /dev/null and b/FiraCode-SemiBold.ttf differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..fce8fe7 --- /dev/null +++ b/main.py @@ -0,0 +1,1532 @@ +import wx +import irc.client +import threading +import re +import time +import traceback +from collections import defaultdict +from datetime import datetime +import socket +import logging +import queue +import os + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class UIUpdate: + """Thread-safe UI update container""" + def __init__(self, callback, *args, **kwargs): + self.callback = callback + self.args = args + self.kwargs = kwargs + +class SearchDialog(wx.Dialog): + def __init__(self, parent): + super().__init__(parent, title="Search", style=wx.DEFAULT_DIALOG_STYLE) + self.parent = parent + + sizer = wx.BoxSizer(wx.VERTICAL) + + # Search input + search_sizer = wx.BoxSizer(wx.HORIZONTAL) + search_sizer.Add(wx.StaticText(self, label="Search:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) + self.search_ctrl = wx.TextCtrl(self, size=(200, -1)) + self.search_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_search) + search_sizer.Add(self.search_ctrl, 1, wx.EXPAND | wx.ALL, 5) + + sizer.Add(search_sizer, 0, wx.EXPAND) + + # Options + options_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.case_sensitive = wx.CheckBox(self, label="Case sensitive") + self.whole_word = wx.CheckBox(self, label="Whole word") + options_sizer.Add(self.case_sensitive, 0, wx.ALL, 5) + options_sizer.Add(self.whole_word, 0, wx.ALL, 5) + + sizer.Add(options_sizer, 0, wx.EXPAND) + + # Buttons + btn_sizer = wx.StdDialogButtonSizer() + self.search_btn = wx.Button(self, wx.ID_OK, "Search") + self.search_btn.SetDefault() + self.search_btn.Bind(wx.EVT_BUTTON, self.on_search) + btn_sizer.AddButton(self.search_btn) + + close_btn = wx.Button(self, wx.ID_CANCEL, "Close") + btn_sizer.AddButton(close_btn) + btn_sizer.Realize() + + sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 10) + + self.SetSizer(sizer) + self.Fit() + + self.search_ctrl.SetFocus() + + def on_search(self, event): + search_text = self.search_ctrl.GetValue().strip() + if search_text: + self.parent.perform_search( + search_text, + self.case_sensitive.IsChecked(), + self.whole_word.IsChecked() + ) + +class IRCPanel(wx.Panel): + def __init__(self, parent, main_frame): + super().__init__(parent) + self.parent = parent + self.main_frame = main_frame + self.messages = [] + + sizer = wx.BoxSizer(wx.VERTICAL) + + # Use a better font for chat + self.text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.TE_AUTO_URL) + self.text_ctrl.SetBackgroundColour(wx.Colour(30, 30, 30)) + self.text_ctrl.SetForegroundColour(wx.Colour(220, 220, 220)) + + # Load Fira Code font + self.font = self.load_fira_code_font() + self.text_ctrl.SetFont(self.font) + + sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0) + + input_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.input_ctrl = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) + self.input_ctrl.SetHint("Type message here …") + self.input_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_send) + self.input_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_key_down) + + send_btn = wx.Button(self, label="Send") + send_btn.SetToolTip("Send message (Enter)") + send_btn.Bind(wx.EVT_BUTTON, self.on_send) + + input_sizer.Add(self.input_ctrl, 1, wx.EXPAND | wx.ALL, 2) + input_sizer.Add(send_btn, 0, wx.ALL, 2) + + sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0) + + self.SetSizer(sizer) + self.target = None + self.history = [] + self.history_pos = -1 + + # Search state + self.search_text = "" + self.search_positions = [] + self.current_search_index = -1 + + # Bind Ctrl+F for search + self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down) + accel_tbl = wx.AcceleratorTable([(wx.ACCEL_CTRL, ord('F'), wx.ID_FIND)]) + self.SetAcceleratorTable(accel_tbl) + self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND) + + def load_fira_code_font(self): + """Load Fira Code font from current directory""" + try: + # First try to add the font to the system and use it + font_path = "FiraCode-Regular.ttf" + if os.path.exists(font_path): + # Try to load the font file directly + font = wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + # On some systems, we might need to use the font by name after ensuring it's available + if wx.TheFontList.FindOrCreateFont(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, "Fira Code"): + font = wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, "Fira Code") + logger.info("Successfully loaded Fira Code font") + else: + # Try creating font from the file path (this works on some wxPython versions) + font = wx.Font(wx.FontInfo(10).Family(wx.FONTFAMILY_TELETYPE).FaceName("Fira Code")) + logger.info("Using Fira Code font via FaceName") + return font + else: + logger.warning("FiraCode-Regular.ttf not found in current directory") + except Exception as e: + logger.error(f"Error loading Fira Code font: {e}") + + # Fall back to system monospace font + font = wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + logger.info("Using system monospace font as fallback") + return font + + def set_target(self, target): + self.target = target + + def add_message(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False): + """Thread-safe message addition with username coloring""" + try: + # Use CallAfter for thread safety + if wx.IsMainThread(): + self._add_message_safe(message, username, username_color, message_color, bold, italic, underline) + else: + wx.CallAfter(self._add_message_safe, message, username, username_color, message_color, bold, italic, underline) + except Exception as e: + logger.error(f"Error in add_message: {e}") + + def _add_message_safe(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False): + """Actually add message - must be called from main thread""" + try: + self.messages.append(message) + + # Save current position for formatting + start_pos = self.text_ctrl.GetLastPosition() + + if username and username_color: + # Add username with its color + attr = wx.TextAttr() + attr.SetTextColour(username_color) + if bold: + attr.SetFontWeight(wx.FONTWEIGHT_BOLD) + if italic: + attr.SetFontStyle(wx.FONTSTYLE_ITALIC) + if underline: + attr.SetFontUnderlined(True) + attr.SetFont(self.font) + self.text_ctrl.SetDefaultStyle(attr) + self.text_ctrl.AppendText(username) + + # Add the rest of the message with message color + attr = wx.TextAttr() + if message_color: + attr.SetTextColour(message_color) + else: + attr.SetTextColour(wx.Colour(220, 220, 220)) + + attr.SetFont(self.font) + self.text_ctrl.SetDefaultStyle(attr) + + # Append the message (without username if we already added it) + if username and username_color: + # Find the message part after username + message_text = message[message.find(username) + len(username):] + self.text_ctrl.AppendText(message_text + "\n") + else: + self.text_ctrl.AppendText(message + "\n") + + # Auto-scroll to bottom + self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition()) + except Exception as e: + logger.error(f"Error adding message safely: {e}") + + def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False): + """Add a formatted message with colored username""" + try: + if is_action: + message = f"{timestamp}* {username} {content}" + self.add_message(message, f"* {username}", username_color, wx.Colour(255, 150, 255), italic=True) + else: + message = f"{timestamp}<{username}> {content}" + self.add_message(message, f"<{username}>", username_color, wx.Colour(220, 220, 220)) + except Exception as e: + logger.error(f"Error adding formatted message: {e}") + + def add_system_message(self, message, color=None, bold=False): + """Add system message without username coloring""" + try: + if color is None: + color = wx.Colour(180, 180, 255) + self.add_message(message, None, None, color, bold, False, False) + except Exception as e: + logger.error(f"Error adding system message: {e}") + + def on_text_key_down(self, event): + """Handle key events in the text control""" + keycode = event.GetKeyCode() + if event.ControlDown() and keycode == ord('F'): + self.on_search(event) + else: + event.Skip() + + def on_search(self, event): + """Open search dialog""" + try: + dlg = SearchDialog(self) + dlg.ShowModal() + dlg.Destroy() + except Exception as e: + logger.error(f"Error in search: {e}") + + def perform_search(self, search_text, case_sensitive=False, whole_word=False): + """Perform text search in the chat history""" + try: + self.search_text = search_text + self.search_positions = [] + self.current_search_index = -1 + + # Get all text + full_text = self.text_ctrl.GetValue() + if not full_text: + return + + # Prepare search parameters + flags = 0 + if not case_sensitive: + flags |= wx.FR_DOWN + if whole_word: + # For whole word, we'll handle manually + pass + + # Find all occurrences + pos = 0 + while pos != -1: + if whole_word: + # Manual whole word search + found_pos = full_text.find(search_text, pos) + if found_pos == -1: + break + # Check if it's a whole word + if (found_pos == 0 or not full_text[found_pos-1].isalnum()) and \ + (found_pos + len(search_text) >= len(full_text) or + not full_text[found_pos + len(search_text)].isalnum()): + self.search_positions.append(found_pos) + pos = found_pos + 1 + else: + found_pos = self.text_ctrl.FindText(pos, len(full_text), search_text, flags) + if found_pos == -1: + break + self.search_positions.append(found_pos) + pos = found_pos + len(search_text) + + if self.search_positions: + self.current_search_index = 0 + self.highlight_search_result() + self.main_frame.SetStatusText(f"Found {len(self.search_positions)} occurrences of '{search_text}'") + else: + self.main_frame.SetStatusText(f"Text '{search_text}' not found") + wx.Bell() + + except Exception as e: + logger.error(f"Error performing search: {e}") + + def highlight_search_result(self): + """Highlight the current search result""" + try: + if not self.search_positions or self.current_search_index < 0: + return + + pos = self.search_positions[self.current_search_index] + + # Select the found text + self.text_ctrl.SetSelection(pos, pos + len(self.search_text)) + self.text_ctrl.ShowPosition(pos) + + # Update status + self.main_frame.SetStatusText( + f"Found {self.current_search_index + 1} of {len(self.search_positions)}: '{self.search_text}'" + ) + except Exception as e: + logger.error(f"Error highlighting search result: {e}") + + def find_next(self): + """Find next occurrence""" + if self.search_positions: + self.current_search_index = (self.current_search_index + 1) % len(self.search_positions) + self.highlight_search_result() + + def find_previous(self): + """Find previous occurrence""" + if self.search_positions: + self.current_search_index = (self.current_search_index - 1) % len(self.search_positions) + self.highlight_search_result() + + def on_key_down(self, event): + try: + keycode = event.GetKeyCode() + if keycode == wx.WXK_UP: + if self.history and self.history_pos < len(self.history) - 1: + self.history_pos += 1 + self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)]) + elif keycode == wx.WXK_DOWN: + if self.history_pos > 0: + self.history_pos -= 1 + self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)]) + elif self.history_pos == 0: + self.history_pos = -1 + self.input_ctrl.Clear() + elif keycode == wx.WXK_TAB: + # Tab completion for nicknames + self.handle_tab_completion() + return # Don't skip to prevent default tab behavior + elif keycode == wx.WXK_F3: + # F3 for find next + if self.search_positions: + self.find_next() + return + elif event.ShiftDown() and keycode == wx.WXK_F3: + # Shift+F3 for find previous + if self.search_positions: + self.find_previous() + return + else: + event.Skip() + except Exception as e: + logger.error(f"Error in key handler: {e}") + event.Skip() + + def handle_tab_completion(self): + """Handle tab completion for nicknames""" + try: + current_text = self.input_ctrl.GetValue() + if not current_text or not self.target or not self.target.startswith('#'): + return + + users = self.main_frame.channel_users.get(self.target, []) + if not users: + return + + # Find word at cursor position + pos = self.input_ctrl.GetInsertionPoint() + text_before = current_text[:pos] + words = text_before.split() + + if not words: + return + + current_word = words[-1] + + # Find matching nicks + matches = [user for user in users if user.lower().startswith(current_word.lower())] + + if matches: + if len(matches) == 1: + # Single match - complete it + new_word = matches[0] + if ':' in text_before or text_before.strip().endswith(current_word): + # Replace the current word + new_text = text_before[:-len(current_word)] + new_word + current_text[pos:] + self.input_ctrl.SetValue(new_text) + self.input_ctrl.SetInsertionPoint(pos - len(current_word) + len(new_word)) + else: + # Multiple matches - show in status + self.main_frame.SetStatusText(f"Tab completion: {', '.join(matches[:5])}{'...' if len(matches) > 5 else ''}") + except Exception as e: + logger.error(f"Error in tab completion: {e}") + + def on_send(self, event): + try: + message = self.input_ctrl.GetValue().strip() + if message and self.target: + self.history.append(message) + self.history_pos = -1 + self.main_frame.send_message(self.target, message) + self.input_ctrl.Clear() + except Exception as e: + logger.error(f"Error sending message: {e}") + +class IRCFrame(wx.Frame): + def __init__(self): + super().__init__(None, title="IRC Client", size=(1200, 700)) + + self.reactor = None + self.connection = None + self.reactor_thread = None + self.connection_lock = threading.RLock() + self.is_connecting = False + self.is_disconnecting = False + self.ui_update_queue = queue.Queue() + self.ui_timer = None + + self.channels = {} + self.channel_users = defaultdict(list) + self.current_channel = None + self.nick = "" + self.server = "" + self.port = 6667 + self.highlights = [] + self.auto_join_channels = [] + self.away = False + self.timestamps = True + + # User color mapping + self.user_colors = {} + self.available_colors = [ + wx.Colour(255, 150, 150), # Light red + wx.Colour(150, 255, 150), # Light green + wx.Colour(150, 200, 255), # Light blue + wx.Colour(255, 255, 150), # Light yellow + wx.Colour(255, 150, 255), # Light magenta + wx.Colour(150, 255, 255), # Light cyan + wx.Colour(255, 200, 150), # Light orange + wx.Colour(200, 150, 255), # Light purple + wx.Colour(255, 200, 200), # Pink + wx.Colour(200, 255, 200), # Mint + wx.Colour(200, 200, 255), # Lavender + wx.Colour(255, 255, 200), # Cream + ] + self.color_index = 0 + + self.setup_irc_handlers() + self.create_menubar() + self.setup_ui() + + # Start UI update timer + self.ui_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.process_ui_updates, self.ui_timer) + self.ui_timer.Start(50) # Process UI updates every 50ms + + self.CreateStatusBar() + self.SetStatusText("Not connected") + + self.Centre() + self.Show() + + self.Bind(wx.EVT_CLOSE, self.on_close) + + # Bind global accelerators for search + accel_tbl = wx.AcceleratorTable([ + (wx.ACCEL_CTRL, ord('F'), wx.ID_FIND), + (wx.ACCEL_NORMAL, wx.WXK_F3, 1001), + (wx.ACCEL_SHIFT, wx.WXK_F3, 1002), + ]) + self.SetAcceleratorTable(accel_tbl) + self.Bind(wx.EVT_MENU, self.on_global_search, id=wx.ID_FIND) + self.Bind(wx.EVT_MENU, self.on_find_next, id=1001) + self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002) + + def get_user_color(self, username): + """Get a consistent color for a username""" + if username not in self.user_colors: + self.user_colors[username] = self.available_colors[self.color_index] + self.color_index = (self.color_index + 1) % len(self.available_colors) + return self.user_colors[username] + + def on_global_search(self, event): + """Handle global Ctrl+F search""" + current_panel = self.get_current_panel() + if current_panel: + current_panel.on_search(event) + + def on_find_next(self, event): + """Handle F3 for find next""" + current_panel = self.get_current_panel() + if current_panel and hasattr(current_panel, 'find_next'): + current_panel.find_next() + + def on_find_previous(self, event): + """Handle Shift+F3 for find previous""" + current_panel = self.get_current_panel() + if current_panel and hasattr(current_panel, 'find_previous'): + current_panel.find_previous() + + def get_current_panel(self): + """Get the currently active IRC panel""" + try: + current_page = self.notebook.GetCurrentPage() + if isinstance(current_page, IRCPanel): + return current_page + except: + pass + return None + + def setup_ui(self): + """Setup UI components""" + panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.HORIZONTAL) + + # Left sidebar + left_panel = wx.Panel(panel) + left_panel.SetBackgroundColour(wx.Colour(45, 45, 45)) + left_sizer = wx.BoxSizer(wx.VERTICAL) + + conn_box = wx.StaticBox(left_panel, label="Connection") + conn_box.SetToolTip("Configure IRC server connection") + conn_box_sizer = wx.StaticBoxSizer(conn_box, wx.VERTICAL) + + conn_box_sizer.Add(wx.StaticText(conn_box, label="Server:"), 0, wx.ALL, 2) + self.server_ctrl = wx.TextCtrl(conn_box, value="irc.libera.chat") + self.server_ctrl.SetToolTip("IRC server address") + conn_box_sizer.Add(self.server_ctrl, 0, wx.EXPAND | wx.ALL, 2) + + conn_box_sizer.Add(wx.StaticText(conn_box, label="Port:"), 0, wx.ALL, 2) + self.port_ctrl = wx.TextCtrl(conn_box, value="6667") + self.port_ctrl.SetToolTip("IRC server port (usually 6667)") + conn_box_sizer.Add(self.port_ctrl, 0, wx.EXPAND | wx.ALL, 2) + + conn_box_sizer.Add(wx.StaticText(conn_box, label="Nick:"), 0, wx.ALL, 2) + self.nick_ctrl = wx.TextCtrl(conn_box, value="wxircuser") + self.nick_ctrl.SetToolTip("Your nickname on IRC") + conn_box_sizer.Add(self.nick_ctrl, 0, wx.EXPAND | wx.ALL, 2) + + self.connect_btn = wx.Button(conn_box, label="Connect") + self.connect_btn.SetToolTip("Connect to IRC server") + self.connect_btn.Bind(wx.EVT_BUTTON, self.on_connect) + conn_box_sizer.Add(self.connect_btn, 0, wx.EXPAND | wx.ALL, 5) + + left_sizer.Add(conn_box_sizer, 0, wx.EXPAND | wx.ALL, 5) + + # Channel management + chan_box = wx.StaticBox(left_panel, label="Channels") + chan_box.SetToolTip("Join and manage channels") + chan_box_sizer = wx.StaticBoxSizer(chan_box, wx.VERTICAL) + + join_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.channel_input = wx.TextCtrl(chan_box, style=wx.TE_PROCESS_ENTER) + self.channel_input.SetHint("Enter channel name …") + self.channel_input.SetToolTip("Type channel name …") + self.channel_input.Bind(wx.EVT_TEXT_ENTER, self.on_join_channel) + join_btn = wx.Button(chan_box, label="Join") + join_btn.SetToolTip("Join channel") + join_btn.Bind(wx.EVT_BUTTON, self.on_join_channel) + + join_sizer.Add(self.channel_input, 1, wx.EXPAND | wx.ALL, 2) + join_sizer.Add(join_btn, 0, wx.ALL, 2) + + self.channel_list = wx.ListBox(chan_box) + self.channel_list.SetToolTip("Joined channels") + self.channel_list.Bind(wx.EVT_LISTBOX, self.on_channel_select) + self.channel_list.Bind(wx.EVT_RIGHT_DOWN, self.on_channel_right_click) + + chan_box_sizer.Add(join_sizer, 0, wx.EXPAND | wx.ALL, 2) + chan_box_sizer.Add(self.channel_list, 1, wx.EXPAND | wx.ALL, 2) + + left_sizer.Add(chan_box_sizer, 1, wx.EXPAND | wx.ALL, 5) + + left_panel.SetSizer(left_sizer) + + # Center - Notebook + self.notebook = wx.Notebook(panel) + + # Server panel + server_panel = IRCPanel(self.notebook, self) + server_panel.set_target("SERVER") + self.notebook.AddPage(server_panel, "Server") + self.channels["SERVER"] = server_panel + + # Right sidebar - Users + right_panel = wx.Panel(panel) + right_panel.SetBackgroundColour(wx.Colour(45, 45, 45)) + right_sizer = wx.BoxSizer(wx.VERTICAL) + + users_box = wx.StaticBox(right_panel, label="Users") + users_box_sizer = wx.StaticBoxSizer(users_box, wx.VERTICAL) + + self.users_list = wx.ListBox(users_box) + self.users_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_user_dclick) + self.users_list.Bind(wx.EVT_RIGHT_DOWN, self.on_user_right_click) + users_box_sizer.Add(self.users_list, 1, wx.EXPAND | wx.ALL, 2) + + right_sizer.Add(users_box_sizer, 1, wx.EXPAND | wx.ALL, 5) + right_panel.SetSizer(right_sizer) + + # Add to main sizer + main_sizer.Add(left_panel, 0, wx.EXPAND | wx.ALL, 0) + main_sizer.Add(self.notebook, 1, wx.EXPAND | wx.ALL, 0) + main_sizer.Add(right_panel, 0, wx.EXPAND | wx.ALL, 0) + + left_panel.SetMinSize((220, -1)) + right_panel.SetMinSize((180, -1)) + + panel.SetSizer(main_sizer) + + def process_ui_updates(self, event): + """Process queued UI updates from timer event""" + try: + while True: + try: + update = self.ui_update_queue.get_nowait() + if update and update.callback: + update.callback(*update.args, **update.kwargs) + except queue.Empty: + break + except Exception as e: + logger.error(f"Error processing UI updates: {e}") + + def safe_ui_update(self, callback, *args, **kwargs): + """Safely update UI from any thread""" + try: + if wx.IsMainThread(): + callback(*args, **kwargs) + else: + self.ui_update_queue.put(UIUpdate(callback, *args, **kwargs)) + except Exception as e: + logger.error(f"Error in safe_ui_update: {e}") + + def create_menubar(self): + try: + menubar = wx.MenuBar() + + # File menu + file_menu = wx.Menu() + file_menu.Append(wx.ID_EXIT, "E&xit\tCtrl+Q") + self.Bind(wx.EVT_MENU, self.on_close, id=wx.ID_EXIT) + + # Edit menu with search + edit_menu = wx.Menu() + edit_menu.Append(wx.ID_FIND, "&Find\tCtrl+F") + self.Bind(wx.EVT_MENU, self.on_global_search, id=wx.ID_FIND) + edit_menu.Append(1001, "Find &Next\tF3") + edit_menu.Append(1002, "Find &Previous\tShift+F3") + self.Bind(wx.EVT_MENU, self.on_find_next, id=1001) + self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002) + + # Channel menu + channel_menu = wx.Menu() + channel_menu.Append(101, "&Join Channel\tCtrl+J") + channel_menu.Append(102, "&Part Channel\tCtrl+P") + channel_menu.AppendSeparator() + channel_menu.Append(103, "Close &Tab\tCtrl+W") + self.Bind(wx.EVT_MENU, self.on_menu_join, id=101) + self.Bind(wx.EVT_MENU, self.on_menu_part, id=102) + self.Bind(wx.EVT_MENU, self.on_menu_close_tab, id=103) + + # Tools menu + tools_menu = wx.Menu() + tools_menu.Append(201, "&WHOIS User\tCtrl+I") + tools_menu.Append(202, "Change &Nick\tCtrl+N") + tools_menu.AppendSeparator() + self.away_item = tools_menu.AppendCheckItem(203, "&Away\tCtrl+A") + self.timestamp_item = tools_menu.AppendCheckItem(204, "Show &Timestamps") + self.timestamp_item.Check(True) + tools_menu.AppendSeparator() + tools_menu.Append(205, "Set &Highlights") + tools_menu.Append(206, "Auto-join Channels") + tools_menu.Append(207, "Command Help") + self.Bind(wx.EVT_MENU, self.on_menu_whois, id=201) + self.Bind(wx.EVT_MENU, self.on_menu_change_nick, id=202) + self.Bind(wx.EVT_MENU, self.on_menu_away, id=203) + self.Bind(wx.EVT_MENU, self.on_menu_timestamps, id=204) + self.Bind(wx.EVT_MENU, self.on_menu_highlights, id=205) + self.Bind(wx.EVT_MENU, self.on_menu_autojoin, id=206) + self.Bind(wx.EVT_MENU, self.on_menu_help, id=207) + + menubar.Append(file_menu, "&File") + menubar.Append(edit_menu, "&Edit") + menubar.Append(channel_menu, "&Channel") + menubar.Append(tools_menu, "&Tools") + + self.SetMenuBar(menubar) + except Exception as e: + logger.error(f"Error creating menu: {e}") + + def setup_irc_handlers(self): + try: + self.reactor = irc.client.Reactor() + self.reactor.add_global_handler("all_events", self.on_all_events) # Catch all events + self.reactor.add_global_handler("welcome", self.on_welcome) + self.reactor.add_global_handler("join", self.on_join) + self.reactor.add_global_handler("part", self.on_part) + self.reactor.add_global_handler("quit", self.on_quit) + self.reactor.add_global_handler("pubmsg", self.on_pubmsg) + self.reactor.add_global_handler("privmsg", self.on_privmsg) + self.reactor.add_global_handler("namreply", self.on_namreply) + self.reactor.add_global_handler("nick", self.on_nick) + self.reactor.add_global_handler("mode", self.on_mode) + self.reactor.add_global_handler("notice", self.on_notice) + self.reactor.add_global_handler("disconnect", self.on_disconnect) + self.reactor.add_global_handler("topic", self.on_topic) + self.reactor.add_global_handler("kick", self.on_kick) + self.reactor.add_global_handler("whoisuser", self.on_whoisuser) + self.reactor.add_global_handler("whoischannels", self.on_whoischannels) + self.reactor.add_global_handler("whoisserver", self.on_whoisserver) + except Exception as e: + logger.error(f"Error setting up IRC handlers: {e}") + + def on_all_events(self, connection, event): + """Catch-all handler to log ALL server events in the Server tab""" + try: + # Don't log certain very frequent events to avoid spam + if event.type in ("pubmsg", "privmsg", "action"): + return + + # Format the raw event for display + event_info = f"RAW: {event.type.upper()}" + if hasattr(event, 'source') and event.source: + event_info += f" from {event.source}" + if hasattr(event, 'target') and event.target: + event_info += f" to {event.target}" + if hasattr(event, 'arguments') and event.arguments: + event_info += f" - {' '.join(event.arguments)}" + + self.log_server(event_info, wx.Colour(180, 180, 255), italic=True) + except Exception as e: + logger.error(f"Error in all_events handler: {e}") + + def get_timestamp(self): + try: + if self.timestamps: + return f"[{datetime.now().strftime('%H:%M:%S')}] " + return "" + except Exception as e: + logger.error(f"Error getting timestamp: {e}") + return "" + + def is_connected(self): + """Thread-safe connection check""" + with self.connection_lock: + return self.connection and self.connection.is_connected() + + def on_connect(self, event): + try: + if not self.is_connected() and not self.is_connecting: + self.is_connecting = True + self.server = self.server_ctrl.GetValue() + + try: + self.port = int(self.port_ctrl.GetValue()) + except ValueError: + self.safe_ui_update(self.log_server, "Invalid port number", wx.Colour(255, 100, 100)) + self.is_connecting = False + return + + self.nick = self.nick_ctrl.GetValue() + + if not self.server or not self.nick: + self.safe_ui_update(self.log_server, "Server and nick are required", wx.Colour(255, 100, 100)) + self.is_connecting = False + return + + self.safe_ui_update(self.connect_btn.Enable, False) + self.safe_ui_update(self.log_server, f"Connecting to {self.server}:{self.port} as {self.nick}...", wx.Colour(150, 150, 255)) + + def connect_thread(): + try: + logger.info(f"Attempting connection to {self.server}:{self.port}") + self.connection = self.reactor.server().connect( + self.server, self.port, self.nick, + username=self.nick, ircname="wxPython IRC Client", + connect_factory=irc.connection.Factory() + ) + + self.reactor_thread = threading.Thread( + target=self.safe_reactor_loop, + name="IRC-Reactor", + daemon=True + ) + self.reactor_thread.start() + + self.safe_ui_update(self.on_connect_success) + + except irc.client.ServerConnectionError as e: + error_msg = f"Connection error: {e}" + logger.error(error_msg) + self.safe_ui_update(self.on_connect_failed, error_msg) + except socket.gaierror as e: + error_msg = f"DNS resolution failed: {e}" + logger.error(error_msg) + self.safe_ui_update(self.on_connect_failed, error_msg) + except Exception as e: + error_msg = f"Unexpected connection error: {e}" + logger.error(error_msg) + self.safe_ui_update(self.on_connect_failed, error_msg) + + threading.Thread(target=connect_thread, name="IRC-Connect", daemon=True).start() + + elif self.is_connected(): + self.disconnect() + + except Exception as e: + logger.error(f"Error in connect handler: {e}") + self.is_connecting = False + self.safe_ui_update(self.connect_btn.Enable, True) + + def safe_reactor_loop(self): + """Safely run the reactor loop with exception handling""" + try: + self.reactor.process_forever() + except Exception as e: + logger.error(f"Reactor loop error: {e}") + self.safe_ui_update(self.log_server, f"Connection error: {e}", wx.Colour(255, 100, 100)) + self.safe_ui_update(self.on_disconnect_cleanup) + + def on_connect_success(self): + """Handle successful connection""" + self.is_connecting = False + self.connect_btn.SetLabel("Disconnect") + self.connect_btn.Enable(True) + self.SetStatusText(f"Connected to {self.server} - Use /help for commands, Ctrl+F to search") + logger.info(f"Successfully connected to {self.server}") + + def on_connect_failed(self, error_msg): + """Handle connection failure""" + self.is_connecting = False + self.log_server(error_msg, wx.Colour(255, 100, 100)) + self.connect_btn.Enable(True) + self.SetStatusText("Connection failed") + logger.error(f"Connection failed: {error_msg}") + + def disconnect(self): + """Safely disconnect from IRC server""" + try: + with self.connection_lock: + if self.connection and self.connection.is_connected(): + self.is_disconnecting = True + self.connection.quit("Goodbye") + + # Give it a moment to send the quit message + threading.Timer(1.0, self.force_disconnect).start() + else: + self.on_disconnect_cleanup() + except Exception as e: + logger.error(f"Error during disconnect: {e}") + self.on_disconnect_cleanup() + + def force_disconnect(self): + """Force disconnect if graceful quit fails""" + try: + with self.connection_lock: + if self.connection: + self.connection.close() + self.on_disconnect_cleanup() + except Exception as e: + logger.error(f"Error during force disconnect: {e}") + self.on_disconnect_cleanup() + + def on_disconnect_cleanup(self): + """Clean up after disconnect""" + with self.connection_lock: + self.connection = None + self.is_connecting = False + self.is_disconnecting = False + + self.safe_ui_update(self.connect_btn.SetLabel, "Connect") + self.safe_ui_update(self.connect_btn.Enable, True) + self.safe_ui_update(self.SetStatusText, "Disconnected") + self.safe_ui_update(self.log_server, "Disconnected from server", wx.Colour(255, 100, 100)) + + def on_join_channel(self, event): + try: + channel = self.channel_input.GetValue().strip() + if channel and self.is_connected(): + if not channel.startswith('#'): + channel = '#' + channel + self.connection.join(channel) + self.channel_input.Clear() + except Exception as e: + logger.error(f"Error joining channel: {e}") + self.safe_ui_update(self.log_server, f"Error joining channel: {e}", wx.Colour(255, 100, 100)) + + def on_channel_select(self, event): + try: + selection = self.channel_list.GetSelection() + if selection != wx.NOT_FOUND: + channel = self.channel_list.GetString(selection) + self.switch_to_channel(channel) + except Exception as e: + logger.error(f"Error selecting channel: {e}") + + def on_channel_right_click(self, event): + try: + selection = self.channel_list.HitTest(event.GetPosition()) + if selection != wx.NOT_FOUND: + channel = self.channel_list.GetString(selection) + menu = wx.Menu() + menu.Append(1, "Part Channel") + menu.Append(2, "Close Tab") + self.Bind(wx.EVT_MENU, lambda e: self.part_channel(channel), id=1) + self.Bind(wx.EVT_MENU, lambda e: self.close_channel(channel), id=2) + self.PopupMenu(menu) + menu.Destroy() + except Exception as e: + logger.error(f"Error in channel right click: {e}") + + def on_user_dclick(self, event): + try: + selection = self.users_list.GetSelection() + if selection != wx.NOT_FOUND: + user = self.users_list.GetString(selection) + self.open_query(user) + except Exception as e: + logger.error(f"Error in user double click: {e}") + + def on_user_right_click(self, event): + try: + selection = self.users_list.HitTest(event.GetPosition()) + if selection != wx.NOT_FOUND: + user = self.users_list.GetString(selection) + menu = wx.Menu() + menu.Append(1, f"Query {user}") + menu.Append(2, f"WHOIS {user}") + if self.current_channel and self.current_channel.startswith('#'): + menu.AppendSeparator() + menu.Append(3, f"Kick {user}") + self.Bind(wx.EVT_MENU, lambda e: self.open_query(user), id=1) + self.Bind(wx.EVT_MENU, lambda e: self.whois_user(user), id=2) + if self.current_channel and self.current_channel.startswith('#'): + self.Bind(wx.EVT_MENU, lambda e: self.kick_user(user), id=3) + self.PopupMenu(menu) + menu.Destroy() + except Exception as e: + logger.error(f"Error in user right click: {e}") + + def open_query(self, user): + try: + if user not in self.channels: + self.add_channel(user) + self.switch_to_channel(user) + except Exception as e: + logger.error(f"Error opening query: {e}") + + def whois_user(self, user): + try: + if self.is_connected(): + self.connection.whois([user]) + except Exception as e: + logger.error(f"Error in WHOIS: {e}") + + def kick_user(self, user): + try: + if self.current_channel and self.current_channel.startswith('#') and self.is_connected(): + reason = wx.GetTextFromUser("Kick reason:", "Kick User", "Kicked") + if reason is not None: + self.connection.kick(self.current_channel, user, reason) + except Exception as e: + logger.error(f"Error kicking user: {e}") + + def switch_to_channel(self, channel): + try: + self.current_channel = channel + for i in range(self.notebook.GetPageCount()): + if self.notebook.GetPageText(i) == channel: + self.notebook.SetSelection(i) + break + + self.update_user_list(channel) + except Exception as e: + logger.error(f"Error switching channel: {e}") + + def update_user_list(self, channel): + """Thread-safe user list update""" + def _update_user_list(): + try: + self.users_list.Clear() + if channel in self.channel_users: + for user in sorted(self.channel_users[channel]): + self.users_list.Append(user) + except Exception as e: + logger.error(f"Error updating user list: {e}") + + self.safe_ui_update(_update_user_list) + + def send_message(self, target, message): + try: + if message.startswith('/'): + self.handle_command(target, message) + elif target != "SERVER" and self.is_connected(): + self.connection.privmsg(target, message) + if target in self.channels: + # Show our own message with our color + user_color = self.get_user_color(self.nick) + timestamp = self.get_timestamp() + self.channels[target].add_formatted_message(timestamp, self.nick, message, user_color) + except Exception as e: + logger.error(f"Error sending message: {e}") + self.safe_ui_update(self.log_server, f"Error sending message: {e}", wx.Colour(255, 100, 100)) + + def handle_command(self, target, message): + try: + parts = message[1:].split(' ', 1) + cmd = parts[0].lower() + args = parts[1] if len(parts) > 1 else "" + + if cmd == "help": + help_text = """ +Available commands: +/join - Join a channel +/part [channel] - Leave current or specified channel +/msg - Send private message +/me - Send action message +/nick - Change nickname +/whois - Get user information +/topic [newtopic] - Get or set channel topic +/kick [reason] - Kick user from channel +/away [message] - Set away status +/quit [message] - Disconnect from server +/help - Show this help + """ + self.log_server(help_text, wx.Colour(200, 255, 200)) + elif cmd == "me": + if self.is_connected(): + self.connection.action(target, args) + user_color = self.get_user_color(self.nick) + timestamp = self.get_timestamp() + self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True) + elif cmd == "nick" and self.is_connected(): + self.connection.nick(args) + elif cmd == "join" and self.is_connected(): + if args and not args.startswith('#'): + args = '#' + args + self.connection.join(args) + elif cmd == "part" and self.is_connected(): + channel = args if args else target + self.connection.part(channel) + elif cmd == "quit" and self.is_connected(): + reason = args if args else "Goodbye" + self.connection.quit(reason) + elif cmd == "msg" and self.is_connected(): + nick_msg = args.split(' ', 1) + if len(nick_msg) == 2: + self.connection.privmsg(nick_msg[0], nick_msg[1]) + elif cmd == "whois" and self.is_connected(): + self.connection.whois([args]) + elif cmd == "kick" and self.is_connected(): + kick_args = args.split(' ', 1) + user = kick_args[0] + reason = kick_args[1] if len(kick_args) > 1 else "Kicked" + self.connection.kick(target, user, reason) + elif cmd == "topic" and self.is_connected(): + if args: + self.connection.topic(target, args) + else: + self.connection.topic(target) + elif cmd == "away" and self.is_connected(): + self.connection.send_raw(f"AWAY :{args}" if args else "AWAY") + self.away = bool(args) + self.safe_ui_update(self.away_item.Check, self.away) + else: + self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 100, 100)) + except Exception as e: + logger.error(f"Error handling command: {e}") + self.safe_ui_update(self.log_server, f"Error executing command: {e}", wx.Colour(255, 100, 100)) + + def part_channel(self, channel): + try: + if self.is_connected(): + self.connection.part(channel) + except Exception as e: + logger.error(f"Error parting channel: {e}") + + def close_channel(self, channel): + try: + if channel in self.channels and channel != "SERVER": + def _close_channel(): + for i in range(self.notebook.GetPageCount()): + if self.notebook.GetPageText(i) == channel: + self.notebook.DeletePage(i) + break + del self.channels[channel] + + idx = self.channel_list.FindString(channel) + if idx != wx.NOT_FOUND: + self.channel_list.Delete(idx) + + self.safe_ui_update(_close_channel) + except Exception as e: + logger.error(f"Error closing channel: {e}") + + def log_server(self, message, color=None, bold=False, italic=False, underline=False): + try: + if "SERVER" in self.channels: + self.channels["SERVER"].add_system_message(f"{self.get_timestamp()}{message}", color, bold) + except Exception as e: + logger.error(f"Error logging server message: {e}") + + def log_channel_message(self, channel, username, message, is_action=False, is_system=False): + """Log a message to a channel with username coloring""" + try: + if channel not in self.channels: + self.safe_ui_update(self.add_channel, channel) + if channel in self.channels: + timestamp = self.get_timestamp() + + if is_system: + # System messages (joins, parts, etc.) + user_color = self.get_user_color(username) + self.channels[channel].add_system_message(f"{timestamp}{message}", user_color) + elif is_action: + # Action messages (/me) + user_color = self.get_user_color(username) + self.channels[channel].add_formatted_message(timestamp, username, message, user_color, is_action=True) + else: + # Regular messages + user_color = self.get_user_color(username) + self.channels[channel].add_formatted_message(timestamp, username, message, user_color) + + # Check for highlights + if self.highlights and any(h.lower() in message.lower() for h in self.highlights): + self.safe_ui_update(wx.Bell) + except Exception as e: + logger.error(f"Error logging channel message: {e}") + + def add_channel(self, channel): + try: + if channel not in self.channels: + panel = IRCPanel(self.notebook, self) + panel.set_target(channel) + self.notebook.AddPage(panel, channel) + self.channels[channel] = panel + + if channel.startswith('#'): + self.channel_list.Append(channel) + except Exception as e: + logger.error(f"Error adding channel: {e}") + + # Menu handlers + def on_menu_join(self, event): + try: + dlg = wx.TextEntryDialog(self, "Enter channel name:", "Join Channel") + if dlg.ShowModal() == wx.ID_OK: + channel = dlg.GetValue() + if channel and self.is_connected(): + if not channel.startswith('#'): + channel = '#' + channel + self.connection.join(channel) + dlg.Destroy() + except Exception as e: + logger.error(f"Error in menu join: {e}") + + def on_menu_part(self, event): + try: + if self.current_channel and self.current_channel != "SERVER": + self.part_channel(self.current_channel) + except Exception as e: + logger.error(f"Error in menu part: {e}") + + def on_menu_close_tab(self, event): + try: + if self.current_channel and self.current_channel != "SERVER": + self.close_channel(self.current_channel) + except Exception as e: + logger.error(f"Error in menu close tab: {e}") + + def on_menu_whois(self, event): + try: + dlg = wx.TextEntryDialog(self, "Enter nickname:", "WHOIS") + if dlg.ShowModal() == wx.ID_OK: + user = dlg.GetValue() + if user and self.is_connected(): + self.whois_user(user) + dlg.Destroy() + except Exception as e: + logger.error(f"Error in menu whois: {e}") + + def on_menu_change_nick(self, event): + try: + dlg = wx.TextEntryDialog(self, "Enter new nickname:", "Change Nick", self.nick) + if dlg.ShowModal() == wx.ID_OK: + new_nick = dlg.GetValue() + if new_nick and self.is_connected(): + self.connection.nick(new_nick) + dlg.Destroy() + except Exception as e: + logger.error(f"Error in menu change nick: {e}") + + def on_menu_away(self, event): + try: + if self.away and self.is_connected(): + self.connection.send_raw("AWAY") + self.away = False + self.SetStatusText(f"Connected to {self.server}") + elif self.is_connected(): + msg = wx.GetTextFromUser("Away message:", "Set Away", "Away from keyboard") + if msg is not None: + self.connection.send_raw(f"AWAY :{msg}") + self.away = True + self.SetStatusText(f"Connected to {self.server} (Away)") + except Exception as e: + logger.error(f"Error in menu away: {e}") + + def on_menu_timestamps(self, event): + try: + self.timestamps = self.timestamp_item.IsChecked() + except Exception as e: + logger.error(f"Error in menu timestamps: {e}") + + def on_menu_highlights(self, event): + try: + current = ", ".join(self.highlights) + dlg = wx.TextEntryDialog(self, "Enter highlight words (comma separated):", + "Set Highlights", current) + if dlg.ShowModal() == wx.ID_OK: + text = dlg.GetValue() + self.highlights = [h.strip() for h in text.split(',') if h.strip()] + dlg.Destroy() + except Exception as e: + logger.error(f"Error in menu highlights: {e}") + + def on_menu_autojoin(self, event): + try: + current = ", ".join(self.auto_join_channels) + dlg = wx.TextEntryDialog(self, "Enter channels to auto-join (comma separated):", + "Auto-join Channels", current) + if dlg.ShowModal() == wx.ID_OK: + text = dlg.GetValue() + self.auto_join_channels = [c.strip() for c in text.split(',') if c.strip()] + dlg.Destroy() + except Exception as e: + logger.error(f"Error in menu autojoin: {e}") + + def on_menu_help(self, event): + """Show command help""" + help_text = """ +IRC Client Help: + +BASIC USAGE: +- Configure server/nick in left panel and click Connect +- Join channels using the join box or /join command +- Type messages in the input box at bottom +- Use Up/Down arrows for message history +- Tab for nickname completion in channels +- Ctrl+F to search in chat history + +TEXT FORMATTING: +- Usernames are colored for easy identification +- URLs are automatically clickable +- Each user has a consistent color + +COMMANDS (type /help in chat for full list): +/join #channel - Join a channel +/part - Leave current channel +/msg nick message - Private message +/me action - Action message +/nick newname - Change nickname +/away [message] - Set away status + """ + self.log_server(help_text, wx.Colour(200, 255, 200), bold=True) + + # IRC Event Handlers - All use thread-safe UI updates + def on_welcome(self, connection, event): + try: + self.log_server("Connected to server!", wx.Colour(100, 255, 100), bold=True) + self.log_server(f"Welcome message: {' '.join(event.arguments)}", wx.Colour(150, 255, 150)) + + # Auto-join channels + for channel in self.auto_join_channels: + if not channel.startswith('#'): + channel = '#' + channel + time.sleep(0.5) + if self.is_connected(): + connection.join(channel) + except Exception as e: + logger.error(f"Error in welcome handler: {e}") + + def on_join(self, connection, event): + try: + nick = event.source.nick + channel = event.target + + if nick == self.nick: + self.safe_ui_update(self.add_channel, channel) + self.log_server(f"Joined channel {channel}", wx.Colour(100, 255, 100)) + + self.log_channel_message(channel, nick, f"→ {nick} joined", is_system=True) + + if nick not in self.channel_users[channel]: + self.channel_users[channel].append(nick) + + if channel == self.current_channel: + self.update_user_list(channel) + except Exception as e: + logger.error(f"Error in join handler: {e}") + + def on_part(self, connection, event): + try: + nick = event.source.nick + channel = event.target + reason = event.arguments[0] if event.arguments else "" + + msg = f"← {nick} left" + if reason: + msg += f" ({reason})" + self.log_channel_message(channel, nick, msg, is_system=True) + + if nick in self.channel_users[channel]: + self.channel_users[channel].remove(nick) + + if channel == self.current_channel: + self.update_user_list(channel) + except Exception as e: + logger.error(f"Error in part handler: {e}") + + def on_quit(self, connection, event): + try: + nick = event.source.nick + reason = event.arguments[0] if event.arguments else "Quit" + + for channel in list(self.channel_users.keys()): + if nick in self.channel_users[channel]: + self.channel_users[channel].remove(nick) + self.log_channel_message(channel, nick, f"← {nick} quit ({reason})", is_system=True) + + if self.current_channel: + self.update_user_list(self.current_channel) + except Exception as e: + logger.error(f"Error in quit handler: {e}") + + def on_pubmsg(self, connection, event): + try: + nick = event.source.nick + channel = event.target + message = event.arguments[0] + + # Check for action messages (/me) + if message.startswith('\x01ACTION') and message.endswith('\x01'): + message = message[8:-1] + self.log_channel_message(channel, nick, message, is_action=True) + else: + self.log_channel_message(channel, nick, message) + + # Highlight own nick in messages + if self.nick.lower() in message.lower(): + self.safe_ui_update(wx.Bell) + except Exception as e: + logger.error(f"Error in pubmsg handler: {e}") + + def on_privmsg(self, connection, event): + try: + nick = event.source.nick + message = event.arguments[0] + + # Check for action messages in private queries too + if message.startswith('\x01ACTION') and message.endswith('\x01'): + message = message[8:-1] + self.log_channel_message(nick, nick, message, is_action=True) + else: + self.log_channel_message(nick, nick, message) + except Exception as e: + logger.error(f"Error in privmsg handler: {e}") + + def on_namreply(self, connection, event): + try: + channel = event.arguments[1] + users = event.arguments[2].split() + + clean_users = [] + for user in users: + user = user.lstrip("@+%&~") + clean_users.append(user) + + self.channel_users[channel].extend(clean_users) + + if channel == self.current_channel: + self.update_user_list(channel) + except Exception as e: + logger.error(f"Error in namreply handler: {e}") + + def on_nick(self, connection, event): + try: + old_nick = event.source.nick + new_nick = event.target + + # Update color mapping for the new nick + if old_nick in self.user_colors: + self.user_colors[new_nick] = self.user_colors[old_nick] + del self.user_colors[old_nick] + + if old_nick == self.nick: + self.nick = new_nick + self.log_server(f"You are now known as {new_nick}", wx.Colour(150, 200, 255), bold=True) + + for channel in self.channel_users: + if old_nick in self.channel_users[channel]: + self.channel_users[channel].remove(old_nick) + self.channel_users[channel].append(new_nick) + self.log_channel_message(channel, new_nick, f"{old_nick} is now known as {new_nick}", is_system=True) + + if self.current_channel: + self.update_user_list(self.current_channel) + except Exception as e: + logger.error(f"Error in nick handler: {e}") + + def on_mode(self, connection, event): + try: + channel = event.target + mode = " ".join(event.arguments) + nick = event.source.nick + + self.log_channel_message(channel, nick, f"Mode {mode} by {nick}", is_system=True) + except Exception as e: + logger.error(f"Error in mode handler: {e}") + + def on_notice(self, connection, event): + try: + nick = event.source.nick if hasattr(event.source, 'nick') else str(event.source) + message = event.arguments[0] + + self.log_server(f"-{nick}- {message}", wx.Colour(255, 150, 255), italic=True) + except Exception as e: + logger.error(f"Error in notice handler: {e}") + + def on_disconnect(self, connection, event): + try: + self.log_server("Disconnected from server", wx.Colour(255, 100, 100), bold=True) + self.safe_ui_update(self.on_disconnect_cleanup) + except Exception as e: + logger.error(f"Error in disconnect handler: {e}") + + def on_topic(self, connection, event): + try: + channel = event.arguments[0] if event.arguments else event.target + topic = event.arguments[1] if len(event.arguments) > 1 else event.arguments[0] + + nick = event.source.nick if hasattr(event.source, 'nick') else "Server" + self.log_channel_message(channel, nick, f"Topic: {topic}", is_system=True) + except Exception as e: + logger.error(f"Error in topic handler: {e}") + + def on_kick(self, connection, event): + try: + channel = event.target + kicked = event.arguments[0] + reason = event.arguments[1] if len(event.arguments) > 1 else "No reason" + kicker = event.source.nick + + self.log_channel_message(channel, kicker, f"{kicked} was kicked by {kicker} ({reason})", is_system=True) + + if kicked in self.channel_users[channel]: + self.channel_users[channel].remove(kicked) + + if channel == self.current_channel: + self.update_user_list(channel) + except Exception as e: + logger.error(f"Error in kick handler: {e}") + + def on_whoisuser(self, connection, event): + try: + nick = event.arguments[0] + user = event.arguments[1] + host = event.arguments[2] + realname = event.arguments[4] + + user_color = self.get_user_color(nick) + self.log_server(f"WHOIS {nick}: {user}@{host} ({realname})", user_color) + except Exception as e: + logger.error(f"Error in whoisuser handler: {e}") + + def on_whoischannels(self, connection, event): + try: + nick = event.arguments[0] + channels = event.arguments[1] + + user_color = self.get_user_color(nick) + self.log_server(f"WHOIS {nick} channels: {channels}", user_color) + except Exception as e: + logger.error(f"Error in whoischannels handler: {e}") + + def on_whoisserver(self, connection, event): + try: + nick = event.arguments[0] + server = event.arguments[1] + + user_color = self.get_user_color(nick) + self.log_server(f"WHOIS {nick} server: {server}", user_color) + except Exception as e: + logger.error(f"Error in whoisserver handler: {e}") + + def on_close(self, event): + try: + # Stop UI timer first + if self.ui_timer and self.ui_timer.IsRunning(): + self.ui_timer.Stop() + + if self.is_connected(): + self.disconnect() + # Give it a moment to disconnect gracefully + wx.CallLater(1000, self.Destroy) + else: + self.Destroy() + except Exception as e: + logger.error(f"Error during close: {e}") + self.Destroy() + +if __name__ == "__main__": + try: + app = wx.App() + frame = IRCFrame() + app.MainLoop() + except Exception as e: + logger.critical(f"Fatal error: {e}") + print(f"Fatal error: {e}") + traceback.print_exc() \ No newline at end of file