diff --git a/src/IRCPanel.py b/src/IRCPanel.py index 3c7d53f..75917c4 100644 --- a/src/IRCPanel.py +++ b/src/IRCPanel.py @@ -1,7 +1,6 @@ import wx import wx.adv import random -import threading import logging from SearchDialog import SearchDialog import traceback @@ -10,174 +9,407 @@ import traceback 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 IRCPanel(wx.Panel): + # 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.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) - - self.text_ctrl = wx.TextCtrl(self, style=wx.TE_READONLY | wx.TE_MULTILINE | 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) - - self.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", ":-|", ":|" - ] - } - - emote_random = random.choice(list(self.kaomoji_groups.values())) # Get a random value ( returns a list and looks like a value of the dict in the code ; ) - emote_random = str(emote_random).strip('[]').split(', ')[random.randint(0, len(emote_random)-1)].strip("'") # Do some formatting for the strings, since they are stored in lists - - self.kaomoji_btn = wx.Button(self, label=f"{emote_random}") - self.kaomoji_btn.SetToolTip(f"Kaomojis {emote_random} ") - 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 + self.current_popup = None # Search state self.search_text = "" self.search_positions = [] self.current_search_index = -1 - # Bind Ctrl+F for search + # 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 + self.text_ctrl = wx.TextCtrl( + self, + style=wx.TE_READONLY | wx.TE_MULTILINE | wx.TE_AUTO_URL + ) + self.text_ctrl.SetFont(self._load_system_font()) + self._apply_theme() 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)]) + + 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) + + # 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 load_system_font(self): - """Load appropriate system font with high DPI support""" + 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: - # Get system DPI scale factor dc = wx.ClientDC(self) - dpi_scale = dc.GetPPI().GetWidth() / 96.0 # 96 is standard DPI + dpi_scale = dc.GetPPI().GetWidth() / 96.0 - # Calculate base font size based on DPI + # Calculate font size based on DPI base_size = 10 if dpi_scale > 1.5: - font_size = int(base_size * 1.5) # 150% scaling + font_size = int(base_size * 1.5) elif dpi_scale > 1.25: - font_size = int(base_size * 1.25) # 125% scaling + font_size = int(base_size * 1.25) 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"), + # Try fonts in preference order + font_preferences = [ + "Consolas", "Courier New", "Monaco", + "DejaVu Sans Mono", "Liberation Mono" ] - for family, face_name in font_families: - font = wx.Font(font_size, family, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, face_name) + 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 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 + # 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 system font: {e}") - # Ultimate fallback - return wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + 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 - plain text only for pixel-perfect scrolling""" + def add_message(self, message, username=None, username_color=None, + message_color=None, bold=False, italic=False, underline=False): + """Thread-safe message addition.""" try: - # Use CallAfter for thread safety if wx.IsMainThread(): - self._add_message_safe(message, username, username_color, message_color, bold, italic, underline) + self._add_message_safe(message) else: - wx.CallAfter(self._add_message_safe, message, username, username_color, message_color, bold, italic, underline) + wx.CallAfter(self._add_message_safe, message) 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 - Note: Without TE_RICH2, color/formatting is not supported, but scrolling is pixel-perfect""" + def _add_message_safe(self, message): + """Add message to display (must be called from main thread).""" try: self.messages.append(message) - # Simple append without formatting (pixel-perfect scrolling) + # 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 message self.text_ctrl.AppendText(message + "\n") - # Auto-scroll to bottom - self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition()) + # 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 safely: {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 message (plain text only for pixel-perfect scrolling)""" + def add_formatted_message(self, timestamp, username, content, + username_color=None, is_action=False): + """Add a formatted IRC message.""" try: if is_action: message = f"{timestamp}* {username} {content}" @@ -188,188 +420,52 @@ class IRCPanel(wx.Panel): logger.error(f"Error adding formatted message: {e}") def add_system_message(self, message, color=None, bold=False): - """Add system message (plain text only for pixel-perfect scrolling)""" + """Add a system message.""" try: self.add_message(message) 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""" + def on_pick_kaomoji(self, event): + """Show the kaomoji picker popup.""" 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 + # Close existing popup if any + if self.current_popup and not self.current_popup.IsBeingDeleted(): + self.current_popup.Dismiss() - # Get all text - full_text = self.text_ctrl.GetValue() - if not full_text or not search_text: - return - - # Prepare search parameters - if not case_sensitive: - search_text_lower = search_text.lower() - full_text_lower = full_text.lower() - - # Manual search implementation - pos = 0 - while pos < len(full_text): - if case_sensitive: - found_pos = full_text.find(search_text, pos) - else: - found_pos = full_text_lower.find(search_text_lower, pos) - - if found_pos == -1: - break - - # For whole word search, verify boundaries - if 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 - 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() + # 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 performing search: {e}") + logger.error(f"Error showing kaomoji picker: {e}") traceback.print_exc() - def highlight_search_result(self): - """Highlight the current search result""" + def on_kaomoji_insert(self, kaomoji): + """Insert selected kaomoji into input.""" 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 + current = self.input_ctrl.GetValue() pos = self.input_ctrl.GetInsertionPoint() - text_before = current_text[:pos] - words = text_before.split() - if not words: - return + # 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 - current_word = words[-1] + # 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() - # 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}") + logger.error(f"Error inserting kaomoji: {e}") def on_send(self, event): - """Send the current input to the active IRC target.""" + """Send the current message.""" try: message = self.input_ctrl.GetValue().strip() if message and self.target: @@ -380,9 +476,184 @@ class IRCPanel(wx.Panel): except Exception as e: logger.error(f"Error sending message: {e}") - + def on_key_down(self, event): + """Handle input field keyboard events.""" + try: + keycode = event.GetKeyCode() + + 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 nickname tab completion.""" + 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 + + pos = self.input_ctrl.GetInsertionPoint() + text_before = current_text[:pos] + 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 given text at the current caret position in the input box.""" + """Insert text at current caret position.""" try: current = self.input_ctrl.GetValue() pos = self.input_ctrl.GetInsertionPoint() @@ -390,145 +661,4 @@ class IRCPanel(wx.Panel): 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: - - - 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 self.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_SUNKEN) - - panel = wx.Panel(popup) - sizer = wx.BoxSizer(wx.VERTICAL) - listbox = wx.ListBox( - panel, choices=display_items, style=wx.LB_SINGLE | wx.BORDER_SUNKEN - ) - 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 self.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.Destroy() # Linux did not like dismiss, so we just destroy it. it works better - - 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}") + logger.error(f"Error inserting text: {e}") \ No newline at end of file