import wx import threading import logging from SearchDialog import SearchDialog import traceback # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) 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 with white theme self.text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.TE_AUTO_URL) # White theme colors self.text_ctrl.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background self.text_ctrl.SetForegroundColour(wx.Colour(0, 0, 0)) # Black text # Load appropriate font self.font = self.load_system_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_system_font(self): """Load appropriate system font with high DPI support""" try: # Get system DPI scale factor dc = wx.ClientDC(self) dpi_scale = dc.GetPPI().GetWidth() / 96.0 # 96 is standard DPI # Calculate base font size based on DPI base_size = 10 if dpi_scale > 1.5: font_size = int(base_size * 1.5) # 150% scaling elif dpi_scale > 1.25: font_size = int(base_size * 1.25) # 125% scaling else: font_size = base_size # Try system fonts in order of preference font_families = [ (wx.FONTFAMILY_TELETYPE, "Consolas"), (wx.FONTFAMILY_TELETYPE, "Courier New"), (wx.FONTFAMILY_TELETYPE, "Monaco"), (wx.FONTFAMILY_TELETYPE, "DejaVu Sans Mono"), (wx.FONTFAMILY_TELETYPE, "Liberation Mono"), ] for family, face_name in font_families: font = wx.Font(font_size, family, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, face_name) if font.IsOk(): logger.info(f"Using font: {face_name} at {font_size}pt") return font # Fallback to default monospace font = wx.Font(font_size, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) logger.info("Using system monospace font as fallback") return font except Exception as e: logger.error(f"Error loading system font: {e}") # Ultimate fallback return wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) 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(0, 0, 0)) # Black text for white theme 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(128, 0, 128), italic=True) # Dark purple for actions else: message = f"{timestamp}<{username}> {content}" self.add_message(message, f"<{username}>", username_color, wx.Colour(0, 0, 0)) # Black text 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(0, 0, 128) # Dark blue for system messages 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 or not search_text: return # Prepare search parameters search_flags = 0 if not case_sensitive: # For manual search, we'll handle case sensitivity ourselves search_text_lower = search_text.lower() full_text_lower = full_text.lower() # Manual search implementation since wx.TextCtrl doesn't have FindText pos = 0 while pos < len(full_text): if case_sensitive: # Case sensitive search found_pos = full_text.find(search_text, pos) else: # Case insensitive search found_pos = full_text_lower.find(search_text_lower, pos) if found_pos == -1: break # For whole word search, verify boundaries if whole_word: # Check if it's a whole word is_word_start = (found_pos == 0 or not full_text[found_pos-1].isalnum()) is_word_end = (found_pos + len(search_text) >= len(full_text) or not full_text[found_pos + len(search_text)].isalnum()) if is_word_start and is_word_end: self.search_positions.append(found_pos) pos = found_pos + 1 # Move forward to avoid infinite loop else: 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}") traceback.print_exc() 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}")