import wx , wx.adv 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 = [] self.theme = getattr(self.main_frame, "theme", None) self.default_text_colour = self.theme["text"] if self.theme else wx.Colour(0, 0, 0) 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) if self.theme: self.text_ctrl.SetBackgroundColour(self.theme["content_bg"]) self.text_ctrl.SetForegroundColour(self.theme["text"]) self.SetBackgroundColour(self.theme["content_bg"]) # 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) # Kaomoji picker button - inserts plain ASCII kaomojis into the input box self.kaomoji_btn = wx.Button(self, label="Emotes") self.kaomoji_btn.SetToolTip("Emotes :3") self.kaomoji_btn.Bind(wx.EVT_BUTTON, self.on_pick_kaomoji) send_btn = wx.Button(self, label="Send") send_btn.SetToolTip("Send message (Enter)") send_btn.Bind(wx.EVT_BUTTON, self.on_send) # Order: input field, kaomoji, send input_sizer.Add(self.input_ctrl, 1, wx.EXPAND | wx.ALL, 2) input_sizer.Add(self.kaomoji_btn, 0, 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(self.default_text_colour) 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, self.default_text_colour) 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): """Send the current input to the active IRC target.""" 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}") def insert_text_at_caret(self, text): """Insert given text at the current caret position in the input box.""" try: current = self.input_ctrl.GetValue() pos = self.input_ctrl.GetInsertionPoint() new_value = current[:pos] + text + current[pos:] self.input_ctrl.SetValue(new_value) self.input_ctrl.SetInsertionPoint(pos + len(text)) except Exception as e: logger.error(f"Error inserting text at caret: {e}") def on_pick_kaomoji(self, event): """ Show a grouped kaomoji popup next to the button and insert the chosen one. """ try: kaomoji_groups = { "Happy": [ ":-)", ":)", ":-D", ":D", "^_^", "^o^", "(*^_^*)", "( ^_^)/", "(:3)", "=)", "=]", "^.^", "UwU", "OwO", "^w^", "^u^", "x3", ":3", ":3c", "nya~", "n_n", "(>ω<)", ":33", "^3^" ], "Sad": [ ":-(", ":'(", "T_T", ";_;", ">_<", "(-_-)", "(_ _)", ], "Angry": [ ">:(", ">:-(", ">:-O", ">.<", "(-_-)#" ], "Love": [ "<3", "(*^3^)", "(^^)v", "(^_^)", "*^_^*" ], "Surprised / Misc": [ ":-O", ":O", ":-0", "O_O", "o_O", "O_o" ], "Sleepy | Bored": [ "-_-", "(-.-) zzz", "(~_~)", "zzz" ], "Gay": [ r"¯\_(._.)_/¯", "(¬_¬)", "(*_*)", "(>_>)", "(<_<)", "(o_O)", "(O_o)", "('_')", "(//▽//)", "(*^///^*)", ">///<", "^_^;", "^///^;", "owo", "uwu", "rawr", ":33p" ], "Blush": [ "^///^", "(//▽//)", "(*^///^*)", ">///<", "^_^;", "^///^;", "(*^▽^*)", "(*´▽`*)" ], "Others": [ "UwU~", "OwO~", ":33", "x3", ":3~", ":3c", "rawr x3", "xD", ";-)", ";)", ":-P", ":P", ":-|", ":|" ] } display_items = [] item_lookup = [] # tuple: (group, index in group) or None for separators/labels # Helper for ASCII filtering def is_ascii(s): return all(ord(ch) < 128 for ch in s) for group, choices in kaomoji_groups.items(): group_ascii_choices = [c for c in choices if is_ascii(c)] if not group_ascii_choices: continue display_items.append(f"──────────── {group} ────────────") item_lookup.append(None) # None means not selectable for idx, choice in enumerate(group_ascii_choices): display_items.append(f" {choice}") item_lookup.append((group, idx)) popup = wx.PopupTransientWindow(self, wx.BORDER_SIMPLE) panel = wx.Panel(popup) sizer = wx.BoxSizer(wx.VERTICAL) listbox = wx.ListBox( panel, choices=display_items, style=wx.LB_SINGLE ) sizer.Add(listbox, 1, wx.EXPAND | wx.ALL, 4) panel.SetSizerAndFit(sizer) popup.SetClientSize(panel.GetBestSize()) # Keep a reference so the popup isn't GC'd self._kaomoji_popup = popup def get_kaomoji_by_index(list_idx): """Given listbox index, return the chosen kaomoji str or None.""" lookup = item_lookup[list_idx] if not lookup: return None group, group_idx = lookup if group: choices = [c for c in kaomoji_groups[group] if is_ascii(c)] return choices[group_idx] else: # Fallback: old style flat return display_items[list_idx].strip() def is_selectable(list_idx): """Whether this list index is a real choice (not a separator/label).""" return item_lookup[list_idx] is not None def on_select(evt): """Handle selection from the kaomoji list (keyboard or programmatic).""" # Ignore synthetic selection changes triggered only for hover visualization if getattr(self, "_suppress_kaomoji_select", False): return try: idx = evt.GetSelection() except AttributeError: # Fallback for synthetic events where we used SetInt() idx = evt.GetInt() if hasattr(evt, "GetInt") else -1 try: if idx is not None and is_selectable(idx): choice = get_kaomoji_by_index(idx) if choice: current = self.input_ctrl.GetValue() pos = self.input_ctrl.GetInsertionPoint() needs_space = pos > 0 and not current[pos - 1].isspace() insert_text = (" " + choice) if needs_space else choice new_value = current[:pos] + insert_text + current[pos:] self.input_ctrl.SetValue(new_value) self.input_ctrl.SetInsertionPoint(pos + len(insert_text)) finally: popup.Dismiss() def on_left_click(evt): """Single left-click handler for the kaomoji menu.""" try: pos = evt.GetPosition() idx = listbox.HitTest(pos) if idx != wx.NOT_FOUND and is_selectable(idx): # Ensure the item is selected, then reuse on_select logic listbox.SetSelection(idx) cmd_evt = wx.CommandEvent(wx.wxEVT_LISTBOX, listbox.GetId()) cmd_evt.SetEventObject(listbox) cmd_evt.SetInt(idx) on_select(cmd_evt) else: evt.Skip() except Exception as e: logger.error(f"Error in kaomoji left-click handler: {e}") def on_motion(evt): """Visual hover selector so the current row is highlighted.""" try: pos = evt.GetPosition() idx = listbox.HitTest(pos) current_sel = listbox.GetSelection() if idx != wx.NOT_FOUND and is_selectable(idx) and idx != current_sel: # Temporarily suppress on_select so hover highlight doesn't send self._suppress_kaomoji_select = True try: listbox.SetSelection(idx) finally: self._suppress_kaomoji_select = False elif idx == wx.NOT_FOUND or not is_selectable(idx): # Optionally clear selection when hovering outside items or on label row self._suppress_kaomoji_select = True try: # wx.ListBox does not have DeselectAll. Use SetSelection(-1) to clear selection. listbox.SetSelection(wx.NOT_FOUND) finally: self._suppress_kaomoji_select = False except Exception as e: logger.error(f"Error in kaomoji hover handler: {e}") finally: evt.Skip() # Single left-click selects and sends; keyboard selection still works listbox.Bind(wx.EVT_LISTBOX, on_select) listbox.Bind(wx.EVT_LEFT_DOWN, on_left_click) listbox.Bind(wx.EVT_MOTION, on_motion) def on_draw_item(event): pass # Position popup under the kaomoji button btn = self.kaomoji_btn btn_pos = btn.ClientToScreen((0, btn.GetSize().height)) popup.Position(btn_pos, (0, 0)) popup.Popup() except Exception as e: logger.error(f"Error in kaomoji picker: {e}")