import wx import wx.adv import random 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 KaomojiPicker(wx.PopupTransientWindow): def __init__(self, parent, kaomoji_groups, on_select_callback): super().__init__(parent, wx.BORDER_SIMPLE) self.on_select_callback = on_select_callback self.kaomoji_groups = kaomoji_groups self._init_ui() self.Bind(wx.EVT_WINDOW_DESTROY, self.on_destroy) def _init_ui(self): panel = wx.Panel(self) main_sizer = wx.BoxSizer(wx.VERTICAL) # Scrolled content area self.scroll = wx.ScrolledWindow(panel, size=(380, 420), style=wx.VSCROLL) self.scroll.SetScrollRate(0, 15) self.scroll_sizer = wx.BoxSizer(wx.VERTICAL) # Storage for filtering self.all_buttons = [] self.group_headers = {} self.group_containers = {} # Build kaomoji groups self._build_kaomoji_groups() self.scroll.SetSizer(self.scroll_sizer) main_sizer.Add(self.scroll, 1, wx.EXPAND) panel.SetSizer(main_sizer) main_sizer.Fit(panel) self.SetClientSize(panel.GetBestSize()) # Bind events self._bind_events(panel) def _build_kaomoji_groups(self): dc = wx.ClientDC(self.scroll) dc.SetFont(self.scroll.GetFont()) self.scroll_sizer.AddSpacer(8) for group_name, kaomojis in self.kaomoji_groups.items(): if not kaomojis: continue # Group header header = self._create_group_header(group_name) self.group_headers[group_name] = header self.scroll_sizer.Add(header, 0, wx.LEFT | wx.TOP | wx.BOTTOM, 12) # Wrap sizer for buttons wrap_sizer = wx.WrapSizer(wx.HORIZONTAL) for kaomoji in kaomojis: btn = self._create_kaomoji_button(kaomoji, dc, group_name, wrap_sizer) self.all_buttons.append(btn) wrap_sizer.Add(btn, 0, wx.ALL, 3) self.group_containers[group_name] = wrap_sizer self.scroll_sizer.Add(wrap_sizer, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 12) def _create_group_header(self, group_name): header = wx.StaticText(self.scroll, label=group_name) font = header.GetFont() font.SetWeight(wx.FONTWEIGHT_BOLD) font.SetPointSize(9) header.SetFont(font) return header def _create_kaomoji_button(self, kaomoji, dc, group_name, wrap_sizer): text_width, text_height = dc.GetTextExtent(kaomoji) btn_width = min(max(text_width + 20, 55), 140) btn_height = text_height + 14 btn = wx.Button(self.scroll, label=kaomoji, size=(btn_width, btn_height)) btn.SetToolTip(f"{kaomoji} - {group_name}") btn._kaomoji_text = kaomoji.lower() btn._kaomoji_group = group_name.lower() btn._group_name = group_name # Bind click event btn.Bind(wx.EVT_BUTTON, lambda evt, k=kaomoji: self.on_kaomoji_selected(k)) return btn def _bind_events(self, panel): panel.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook) self.Bind(wx.EVT_KILL_FOCUS, self.on_kill_focus) def on_kaomoji_selected(self, kaomoji): try: if self.on_select_callback: self.on_select_callback(kaomoji) except Exception as e: logger.error(f"Error in kaomoji selection callback: {e}") finally: # Safely dismiss the popup wx.CallAfter(self.safe_dismiss) def on_search(self, event): try: search_text = self.search_ctrl.GetValue().lower().strip() if not search_text: self._show_all() return visible_groups = set() for btn in self.all_buttons: # Match kaomoji text or group name is_match = (search_text in btn._kaomoji_text or search_text in btn._kaomoji_group) btn.Show(is_match) if is_match: visible_groups.add(btn._group_name) # Show/hide group headers for group_name, header in self.group_headers.items(): header.Show(group_name in visible_groups) self.scroll.Layout() self.scroll.FitInside() except Exception as e: logger.error(f"Error in kaomoji search: {e}") def on_clear_search(self, event): self.search_ctrl.SetValue("") self._show_all() def _show_all(self): for btn in self.all_buttons: btn.Show() for header in self.group_headers.values(): header.Show() self.scroll.Layout() self.scroll.FitInside() def on_char_hook(self, event): if event.GetKeyCode() == wx.WXK_ESCAPE: self.safe_dismiss() else: event.Skip() def on_kill_focus(self, event): focused = wx.Window.FindFocus() if focused and (focused == self.search_ctrl or focused.GetParent() == self.scroll or self.scroll.IsDescendant(focused)): event.Skip() return wx.CallLater(100, self.safe_dismiss) event.Skip() def on_destroy(self, event): self.all_buttons.clear() self.group_headers.clear() self.group_containers.clear() event.Skip() def safe_dismiss(self): if not self.IsBeingDeleted() and self.IsShown(): try: self.Dismiss() except Exception as e: logger.error(f"Error dismissing popup: {e}") def show_at_button(self, button): btn_screen_pos = button.ClientToScreen((0, 0)) popup_size = self.GetSize() display_size = wx.GetDisplaySize() # Try to show above the button, otherwise below if btn_screen_pos.y - popup_size.height > 0: popup_y = btn_screen_pos.y - popup_size.height else: popup_y = btn_screen_pos.y + button.GetSize().height # Keep popup on screen horizontally popup_x = max(10, min(btn_screen_pos.x, display_size.x - popup_size.width - 10)) self.Position((popup_x, popup_y), (0, 0)) self.Popup() class CommandAutocomplete(wx.PopupTransientWindow): """Popup window for IRC command autocomplete, similar to Minecraft.""" def __init__(self, parent, commands, on_select_callback): super().__init__(parent, wx.BORDER_SIMPLE) self.on_select_callback = on_select_callback self.commands = commands # List of (command, description) tuples self.filtered_commands = commands.copy() self.selected_index = 0 self._init_ui() self.Bind(wx.EVT_WINDOW_DESTROY, self.on_destroy) def _init_ui(self): panel = wx.Panel(self) main_sizer = wx.BoxSizer(wx.VERTICAL) # Command list self.list_ctrl = wx.ListCtrl(panel, style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_NO_HEADER) self.list_ctrl.InsertColumn(0, "Command", width=120) self.list_ctrl.InsertColumn(1, "Description", width=280) self._update_list() # Bind events self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated) self.list_ctrl.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_item_selected) self.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook) self.Bind(wx.EVT_KILL_FOCUS, self.on_kill_focus) main_sizer.Add(self.list_ctrl, 1, wx.EXPAND | wx.ALL, 2) panel.SetSizer(main_sizer) main_sizer.Fit(panel) self.SetClientSize(panel.GetBestSize()) # Select first item if self.list_ctrl.GetItemCount() > 0: self.list_ctrl.Select(0) self.selected_index = 0 def _update_list(self): """Update the list with filtered commands.""" self.list_ctrl.DeleteAllItems() for cmd, desc in self.filtered_commands: idx = self.list_ctrl.InsertItem(self.list_ctrl.GetItemCount(), cmd) self.list_ctrl.SetItem(idx, 1, desc) # Resize to fit content (max 8 items visible) item_height = 20 max_items = min(8, len(self.filtered_commands)) self.list_ctrl.SetSize((410, item_height * max_items + 4)) self.SetClientSize((410, item_height * max_items + 4)) def filter_commands(self, search_text): """Filter commands based on search text.""" search_lower = search_text.lower().strip() if not search_lower: self.filtered_commands = self.commands.copy() else: self.filtered_commands = [ (cmd, desc) for cmd, desc in self.commands if cmd.lower().startswith(search_lower) ] self.selected_index = 0 self._update_list() if self.list_ctrl.GetItemCount() > 0: self.list_ctrl.Select(0) def on_item_activated(self, event): """Handle double-click or Enter on item.""" idx = event.GetIndex() if 0 <= idx < len(self.filtered_commands): cmd, _ = self.filtered_commands[idx] if self.on_select_callback: self.on_select_callback(cmd) self.safe_dismiss() def on_item_selected(self, event): """Handle item selection.""" self.selected_index = event.GetIndex() def select_next(self): """Select next item.""" if self.list_ctrl.GetItemCount() > 0: self.selected_index = (self.selected_index + 1) % self.list_ctrl.GetItemCount() self.list_ctrl.Select(self.selected_index) self.list_ctrl.EnsureVisible(self.selected_index) def select_previous(self): """Select previous item.""" if self.list_ctrl.GetItemCount() > 0: self.selected_index = (self.selected_index - 1) % self.list_ctrl.GetItemCount() self.list_ctrl.Select(self.selected_index) self.list_ctrl.EnsureVisible(self.selected_index) def get_selected_command(self): """Get the currently selected command.""" if 0 <= self.selected_index < len(self.filtered_commands): return self.filtered_commands[self.selected_index][0] return None def on_char_hook(self, event): """Handle keyboard events.""" keycode = event.GetKeyCode() if keycode == wx.WXK_ESCAPE: self.safe_dismiss() elif keycode == wx.WXK_UP: self.select_previous() elif keycode == wx.WXK_DOWN: self.select_next() elif keycode == wx.WXK_RETURN or keycode == wx.WXK_NUMPAD_ENTER: cmd = self.get_selected_command() if cmd and self.on_select_callback: self.on_select_callback(cmd) self.safe_dismiss() else: event.Skip() def on_kill_focus(self, event): """Handle focus loss.""" focused = wx.Window.FindFocus() if focused and (focused == self.list_ctrl or self.IsDescendant(focused)): event.Skip() return wx.CallLater(100, self.safe_dismiss) event.Skip() def on_destroy(self, event): event.Skip() def safe_dismiss(self): """Safely dismiss the popup.""" if not self.IsBeingDeleted() and self.IsShown(): try: self.Dismiss() except Exception as e: logger.error(f"Error dismissing command autocomplete: {e}") def show_at_input(self, input_ctrl): """Show popup near the input control.""" input_screen_pos = input_ctrl.ClientToScreen((0, 0)) popup_size = self.GetSize() display_size = wx.GetDisplaySize() # Show above the input, otherwise below if input_screen_pos.y - popup_size.height > 0: popup_y = input_screen_pos.y - popup_size.height - 2 else: popup_y = input_screen_pos.y + input_ctrl.GetSize().height + 2 # Keep popup on screen horizontally popup_x = max(10, min(input_screen_pos.x, display_size.x - popup_size.width - 10)) self.Position((popup_x, popup_y), (0, 0)) self.Popup() class IRCPanel(wx.Panel): # IRC commands with descriptions IRC_COMMANDS = [ ("help", "Show available commands"), ("join", "Join a channel - /join "), ("part", "Leave current or specified channel - /part [channel]"), ("msg", "Send private message - /msg "), ("me", "Send action message - /me "), ("nick", "Change nickname - /nick "), ("whois", "Get user information - /whois "), ("topic", "Get or set channel topic - /topic [newtopic]"), ("kick", "Kick user from channel - /kick [reason]"), ("away", "Set away status - /away [message]"), ("quit", "Disconnect from server - /quit [message]"), ] # Default kaomoji groups 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": [ ":-O", ":O", ":-0", "O_O", "o_O", "O_o" ], "Sleepy": [ "-_-", "(-.-) zzz", "(~_~)", "zzz" ], "Shrug": [ r"¯\_(._.)_/¯", "(¬_¬)", "(*_*)", "(>_>)", "(<_<)", "(o_O)", "(O_o)", "('_')" ], "Blush": [ "^///^", "(//▽//)", "(*^///^*)", ">///<", "^_^;", "^///^;", "(*^▽^*)", "(*´▽`*)" ], "Other": [ "owo", "rawr", ":33p", "xD", ";-)", ";)", ":-P", ":P", ":-|", ":|" ] } def __init__(self, parent, main_frame): super().__init__(parent) self.parent = parent self.main_frame = main_frame self.messages = [] self.target = None self.history = [] self.history_pos = -1 self.current_popup = None self.command_popup = None # Search state self.search_text = "" self.search_positions = [] self.current_search_index = -1 # Theme setup self.theme = getattr(self.main_frame, "theme", None) self.default_text_colour = self.theme["text"] if self.theme else wx.Colour(0, 0, 0) self._init_ui() self._setup_accelerators() def _init_ui(self): """Initialize the UI components.""" sizer = wx.BoxSizer(wx.VERTICAL) # Text display - use TE_RICH2 for styled text support (especially on Windows) self.text_ctrl = wx.TextCtrl( self, style=wx.TE_READONLY | wx.TE_MULTILINE | wx.TE_AUTO_URL | wx.TE_RICH2 ) self.text_ctrl.SetFont(self._load_system_font()) self._apply_theme() self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down) sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0) # Input area input_sizer = self._create_input_area() sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0) self.SetSizer(sizer) def _create_input_area(self): """Create the input controls.""" input_sizer = wx.BoxSizer(wx.HORIZONTAL) # Text input 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) self.input_ctrl.Bind(wx.EVT_TEXT, self.on_input_text_change) # Kaomoji button random_kaomoji = self._get_random_kaomoji() self.kaomoji_btn = wx.Button(self, label=random_kaomoji) self.kaomoji_btn.SetToolTip(f"Kaomojis {random_kaomoji}") self.kaomoji_btn.Bind(wx.EVT_BUTTON, self.on_pick_kaomoji) # Send button 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(self.kaomoji_btn, 0, wx.ALL, 2) input_sizer.Add(send_btn, 0, wx.ALL, 2) return input_sizer def _setup_accelerators(self): """Setup keyboard shortcuts.""" 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 _apply_theme(self): """Apply theme colors if available.""" if self.theme: self.text_ctrl.SetBackgroundColour(self.theme["content_bg"]) self.text_ctrl.SetForegroundColour(self.theme["text"]) self.SetBackgroundColour(self.theme["content_bg"]) def _load_system_font(self): """Load appropriate system font with DPI awareness.""" try: dc = wx.ClientDC(self) dpi_scale = dc.GetPPI().GetWidth() / 96.0 # Calculate font size based on DPI base_size = 10 if dpi_scale > 1.5: font_size = int(base_size * 1.5) elif dpi_scale > 1.25: font_size = int(base_size * 1.25) else: font_size = base_size # Try fonts in preference order font_preferences = [ "Consolas", "Courier New", "Monaco", "DejaVu Sans Mono", "Liberation Mono" ] for face_name in font_preferences: font = wx.Font( font_size, wx.FONTFAMILY_TELETYPE, 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 logger.info("Using system monospace font") return wx.Font( font_size, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL ) except Exception as e: logger.error(f"Error loading font: {e}") return wx.Font( 10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL ) def _get_random_kaomoji(self): """Get a random kaomoji for the button label.""" all_kaomojis = [k for group in self.KAOMOJI_GROUPS.values() for k in group] return random.choice(all_kaomojis) if all_kaomojis else ":)" def set_target(self, target): """Set the current chat 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.""" try: if wx.IsMainThread(): self._add_message_safe(message, username_color, message_color, bold, italic, underline) else: wx.CallAfter(self._add_message_safe, message, 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_color=None, message_color=None, bold=False, italic=False, underline=False): """Add message to display with formatting (must be called from main thread).""" try: # Safety check: ensure text_ctrl still exists if not self.text_ctrl or not self: return self.messages.append(message) # Check if user is at bottom last_pos = self.text_ctrl.GetLastPosition() current_pos = self.text_ctrl.GetInsertionPoint() at_bottom = (last_pos - current_pos) <= 10 # Create text attribute for styling text_attr = wx.TextAttr() if message_color: text_attr.SetTextColour(message_color) else: text_attr.SetTextColour(self.default_text_colour) if bold: pass # i hate bold if italic: text_attr.SetFontStyle(wx.FONTSTYLE_ITALIC) if underline: text_attr.SetUnderlined(True) # Set style and append text self.text_ctrl.SetDefaultStyle(text_attr) self.text_ctrl.AppendText(message + "\n") # Reset to default style default_attr = wx.TextAttr() default_attr.SetTextColour(self.default_text_colour) self.text_ctrl.SetDefaultStyle(default_attr) # Auto-scroll if at bottom if at_bottom: self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition()) self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition()) except Exception as e: logger.error(f"Error adding message: {e}") def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False): """Add a formatted IRC message with colored username.""" try: # Check if user is at bottom last_pos = self.text_ctrl.GetLastPosition() current_pos = self.text_ctrl.GetInsertionPoint() at_bottom = (last_pos - current_pos) <= 10 # Append timestamp with default color default_attr = wx.TextAttr() default_attr.SetTextColour(self.default_text_colour) self.text_ctrl.SetDefaultStyle(default_attr) self.text_ctrl.AppendText(timestamp) # Append action marker or opening bracket if is_action: self.text_ctrl.AppendText("* ") else: self.text_ctrl.AppendText("<") # Append username with color if username_color: username_attr = wx.TextAttr() username_attr.SetTextColour(username_color) username_attr.SetFontWeight(wx.FONTWEIGHT_BOLD) self.text_ctrl.SetDefaultStyle(username_attr) else: default_attr.SetFontWeight(wx.FONTWEIGHT_BOLD) self.text_ctrl.SetDefaultStyle(default_attr) self.text_ctrl.AppendText(username) # Append closing bracket and content with default color default_attr.SetFontWeight(wx.FONTWEIGHT_NORMAL) self.text_ctrl.SetDefaultStyle(default_attr) if not is_action: self.text_ctrl.AppendText("> ") else: self.text_ctrl.AppendText(" ") self.text_ctrl.AppendText(content) self.text_ctrl.AppendText("\n") # Auto-scroll if at bottom if at_bottom: self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition()) self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition()) except Exception as e: logger.error(f"Error adding formatted message: {e}") def add_system_message(self, message, color=None, bold=False): """Add a system message with optional color and bold formatting.""" try: self.add_message(message, message_color=color, bold=bold) except Exception as e: logger.error(f"Error adding system message: {e}") def on_pick_kaomoji(self, event): """Show the kaomoji picker popup.""" try: # Close existing popup if any if self.current_popup and not self.current_popup.IsBeingDeleted(): self.current_popup.Dismiss() # Create new popup self.current_popup = KaomojiPicker( self, self.KAOMOJI_GROUPS, self.on_kaomoji_insert ) self.current_popup.show_at_button(self.kaomoji_btn) except Exception as e: logger.error(f"Error showing kaomoji picker: {e}") traceback.print_exc() def on_kaomoji_insert(self, kaomoji): """Insert selected kaomoji into input.""" try: current = self.input_ctrl.GetValue() pos = self.input_ctrl.GetInsertionPoint() # Add space before kaomoji if needed needs_space = pos > 0 and current[pos - 1] not in (' ', '\t', '\n') insert_text = f" {kaomoji}" if needs_space else kaomoji # Insert at cursor new_value = current[:pos] + insert_text + current[pos:] self.input_ctrl.SetValue(new_value) self.input_ctrl.SetInsertionPoint(pos + len(insert_text)) self.input_ctrl.SetFocus() except Exception as e: logger.error(f"Error inserting kaomoji: {e}") def on_send(self, event): """Send the current message.""" try: self._hide_command_popup() # Hide command popup when sending 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 on_input_text_change(self, event): """Handle text changes in input field to show command autocomplete.""" try: current_text = self.input_ctrl.GetValue() pos = self.input_ctrl.GetInsertionPoint() # Check if we're typing a command (starts with /) if current_text.startswith('/') and pos > 0: # Extract the command part (everything after / up to cursor or space) text_before_cursor = current_text[:pos] if ' ' in text_before_cursor: # Command already has arguments, don't show autocomplete self._hide_command_popup() return # Get the command being typed command_part = text_before_cursor[1:] # Remove the '/' self._show_command_popup(command_part) else: self._hide_command_popup() except Exception as e: logger.error(f"Error in text change handler: {e}") event.Skip() def _show_command_popup(self, command_part=""): """Show or update command autocomplete popup.""" try: if not self.command_popup or self.command_popup.IsBeingDeleted(): # Create new popup self.command_popup = CommandAutocomplete( self, self.IRC_COMMANDS, self.on_command_select ) self.command_popup.show_at_input(self.input_ctrl) # Filter commands self.command_popup.filter_commands(command_part) except Exception as e: logger.error(f"Error showing command popup: {e}") def _hide_command_popup(self): """Hide command autocomplete popup.""" try: if self.command_popup and not self.command_popup.IsBeingDeleted(): self.command_popup.safe_dismiss() self.command_popup = None except Exception as e: logger.error(f"Error hiding command popup: {e}") def on_command_select(self, command): """Handle command selection from autocomplete.""" try: current_text = self.input_ctrl.GetValue() pos = self.input_ctrl.GetInsertionPoint() # Find the command part (from / to cursor or space) text_before_cursor = current_text[:pos] if text_before_cursor.startswith('/'): # Replace the command part with the selected command if ' ' in text_before_cursor: # Has arguments, keep them space_pos = text_before_cursor.find(' ') new_text = f"/{command}{current_text[space_pos:]}" else: # No arguments, just replace command new_text = f"/{command} {current_text[pos:]}" self.input_ctrl.SetValue(new_text) # Position cursor after command and space new_pos = len(f"/{command} ") self.input_ctrl.SetInsertionPoint(new_pos) self.input_ctrl.SetFocus() self._hide_command_popup() except Exception as e: logger.error(f"Error selecting command: {e}") def on_key_down(self, event): """Handle input field keyboard events.""" try: keycode = event.GetKeyCode() # Handle command popup navigation if self.command_popup and not self.command_popup.IsBeingDeleted() and self.command_popup.IsShown(): if keycode == wx.WXK_UP: self.command_popup.select_previous() return # Don't skip, handled elif keycode == wx.WXK_DOWN: self.command_popup.select_next() return # Don't skip, handled elif keycode == wx.WXK_RETURN or keycode == wx.WXK_NUMPAD_ENTER: # If popup is shown, select command instead of sending cmd = self.command_popup.get_selected_command() if cmd: self.on_command_select(cmd) return # Don't skip, handled elif keycode == wx.WXK_ESCAPE: self._hide_command_popup() return # Don't skip, handled if keycode == wx.WXK_UP: self._navigate_history_up() elif keycode == wx.WXK_DOWN: self._navigate_history_down() elif keycode == wx.WXK_TAB: self.handle_tab_completion() return # Don't skip elif keycode == wx.WXK_F3: if event.ShiftDown(): self.find_previous() else: self.find_next() return else: event.Skip() except Exception as e: logger.error(f"Error in key handler: {e}") event.Skip() def _navigate_history_up(self): """Navigate up in message history.""" 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)]) def _navigate_history_down(self): """Navigate down in message history.""" 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() def handle_tab_completion(self): """Handle tab completion for commands and nicknames.""" try: current_text = self.input_ctrl.GetValue() if not current_text: return pos = self.input_ctrl.GetInsertionPoint() text_before = current_text[:pos] # Check if we're completing a command (starts with / and no space yet) if text_before.startswith('/') and ' ' not in text_before: command_part = text_before[1:] # Remove the '/' matches = [cmd for cmd, _ in self.IRC_COMMANDS if cmd.lower().startswith(command_part.lower())] if len(matches) == 1: # Single match - complete it new_text = f"/{matches[0]} {current_text[pos:]}" self.input_ctrl.SetValue(new_text) self.input_ctrl.SetInsertionPoint(len(f"/{matches[0]} ")) elif len(matches) > 1: # Multiple matches - show autocomplete popup or status if not self.command_popup or self.command_popup.IsBeingDeleted(): self._show_command_popup(command_part) else: # Update existing popup self.command_popup.filter_commands(command_part) display = ', '.join(matches[:5]) if len(matches) > 5: display += '...' self.main_frame.SetStatusText(f"Tab completion: {display}") return # Otherwise, try nickname completion (only in channels) if not self.target or not self.target.startswith('#'): return users = self.main_frame.channel_users.get(self.target, []) if not users: return words = text_before.split() if not words: return current_word = words[-1] matches = [u for u in users if u.lower().startswith(current_word.lower())] if len(matches) == 1: # Single match - complete it new_word = matches[0] 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)) elif len(matches) > 1: # Multiple matches - show options display = ', '.join(matches[:5]) if len(matches) > 5: display += '...' self.main_frame.SetStatusText(f"Tab completion: {display}") except Exception as e: logger.error(f"Error in tab completion: {e}") # Search functionality def on_text_key_down(self, event): """Handle text control keyboard events.""" if event.ControlDown() and event.GetKeyCode() == 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 opening search: {e}") def perform_search(self, search_text, case_sensitive=False, whole_word=False): """Perform text search in chat history.""" try: self.search_text = search_text self.search_positions = [] self.current_search_index = -1 full_text = self.text_ctrl.GetValue() if not full_text or not search_text: return # Prepare for case-insensitive search search_lower = search_text.lower() if not case_sensitive else search_text text_lower = full_text.lower() if not case_sensitive else full_text # Find all occurrences pos = 0 while pos < len(full_text): found_pos = text_lower.find(search_lower, pos) if found_pos == -1: break # Check whole word constraint if whole_word: is_start = found_pos == 0 or not full_text[found_pos - 1].isalnum() is_end = (found_pos + len(search_text) >= len(full_text) or not full_text[found_pos + len(search_text)].isalnum()) if is_start and is_end: self.search_positions.append(found_pos) pos = found_pos + 1 else: self.search_positions.append(found_pos) pos = found_pos + len(search_text) # Show results 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 current search result.""" try: if not self.search_positions or self.current_search_index < 0: return pos = self.search_positions[self.current_search_index] self.text_ctrl.SetSelection(pos, pos + len(self.search_text)) self.text_ctrl.ShowPosition(pos) self.main_frame.SetStatusText( f"Found {self.current_search_index + 1} of " f"{len(self.search_positions)}: '{self.search_text}'" ) except Exception as e: logger.error(f"Error highlighting search: {e}") def find_next(self): """Find next search result.""" 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 search result.""" if self.search_positions: self.current_search_index = ( (self.current_search_index - 1) % len(self.search_positions) ) self.highlight_search_result() def insert_text_at_caret(self, text): """Insert text at current caret position.""" 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: {e}")