diff --git a/src/IRCPanel.py b/src/IRCPanel.py index 75917c4..1dc0134 100644 --- a/src/IRCPanel.py +++ b/src/IRCPanel.py @@ -200,7 +200,179 @@ class KaomojiPicker(wx.PopupTransientWindow): 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": [ @@ -245,6 +417,7 @@ class IRCPanel(wx.Panel): self.history = [] self.history_pos = -1 self.current_popup = None + self.command_popup = None # Search state self.search_text = "" @@ -262,10 +435,10 @@ class IRCPanel(wx.Panel): """Initialize the UI components.""" sizer = wx.BoxSizer(wx.VERTICAL) - # Text display + # 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 + 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() @@ -288,6 +461,7 @@ class IRCPanel(wx.Panel): 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() @@ -380,14 +554,15 @@ class IRCPanel(wx.Panel): """Thread-safe message addition.""" try: if wx.IsMainThread(): - self._add_message_safe(message) + self._add_message_safe(message, username_color, message_color, bold, italic, underline) else: - wx.CallAfter(self._add_message_safe, message) + 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): - """Add message to display (must be called from main thread).""" + 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: self.messages.append(message) @@ -396,9 +571,29 @@ class IRCPanel(wx.Panel): current_pos = self.text_ctrl.GetInsertionPoint() at_bottom = (last_pos - current_pos) <= 10 - # Append message + # 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()) @@ -409,20 +604,60 @@ class IRCPanel(wx.Panel): def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False): - """Add a formatted IRC message.""" + """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: - message = f"{timestamp}* {username} {content}" + self.text_ctrl.AppendText("* ") else: - message = f"{timestamp}<{username}> {content}" - self.add_message(message) + 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.""" + """Add a system message with optional color and bold formatting.""" try: - self.add_message(message) + self.add_message(message, message_color=color, bold=bold) except Exception as e: logger.error(f"Error adding system message: {e}") @@ -467,6 +702,7 @@ class IRCPanel(wx.Panel): 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) @@ -476,11 +712,110 @@ class IRCPanel(wx.Panel): 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: @@ -517,20 +852,47 @@ class IRCPanel(wx.Panel): self.input_ctrl.Clear() def handle_tab_completion(self): - """Handle nickname tab completion.""" + """Handle tab completion for commands and nicknames.""" try: current_text = self.input_ctrl.GetValue() - if not current_text or not self.target or not self.target.startswith('#'): + 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 - pos = self.input_ctrl.GetInsertionPoint() - text_before = current_text[:pos] words = text_before.split() - if not words: return diff --git a/src/main.py b/src/main.py index 8228315..b220612 100644 --- a/src/main.py +++ b/src/main.py @@ -1059,38 +1059,76 @@ Available commands: 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) + elif cmd == "nick": + if self.is_connected(): + self.connection.nick(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) + self.safe_ui_update(self.log_server, f"Not connected. Cannot change nickname.", wx.Colour(255, 0, 0)) + elif cmd == "join": + if self.is_connected(): + if not args: + self.safe_ui_update(self.log_server, f"Usage: /join ", wx.Colour(255, 0, 0)) + else: + if not args.startswith('#'): + args = '#' + args + self.connection.join(args) + else: + self.safe_ui_update(self.log_server, f"Not connected. Cannot join channel.", wx.Colour(255, 0, 0)) + elif cmd == "part": + if self.is_connected(): + channel = args if args else target + self.connection.part(channel) + else: + self.safe_ui_update(self.log_server, f"Not connected. Cannot part channel.", wx.Colour(255, 0, 0)) + elif cmd == "quit": + if self.is_connected(): + reason = args if args else "Goodbye" + self.connection.quit(reason) + else: + self.safe_ui_update(self.log_server, f"Not connected.", wx.Colour(255, 0, 0)) + elif cmd == "msg": + if self.is_connected(): + nick_msg = args.split(' ', 1) + if len(nick_msg) == 2: + self.connection.privmsg(nick_msg[0], nick_msg[1]) + else: + self.safe_ui_update(self.log_server, f"Usage: /msg ", wx.Colour(255, 0, 0)) + else: + self.safe_ui_update(self.log_server, f"Not connected. Cannot send private message.", wx.Colour(255, 0, 0)) + elif cmd == "whois": + if self.is_connected(): + if args: + self.connection.whois([args]) + else: + self.safe_ui_update(self.log_server, f"Usage: /whois ", wx.Colour(255, 0, 0)) + else: + self.safe_ui_update(self.log_server, f"Not connected. Cannot perform WHOIS.", wx.Colour(255, 0, 0)) + elif cmd == "kick": + if self.is_connected(): + if args: + 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) + else: + self.safe_ui_update(self.log_server, f"Usage: /kick [reason]", wx.Colour(255, 0, 0)) + else: + self.safe_ui_update(self.log_server, f"Not connected. Cannot kick user.", wx.Colour(255, 0, 0)) + elif cmd == "topic": + if self.is_connected(): + if args: + self.connection.topic(target, args) + else: + self.connection.topic(target) + else: + self.safe_ui_update(self.log_server, f"Not connected. Cannot get/set topic.", wx.Colour(255, 0, 0)) + elif cmd == "away": + if 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"Not connected. Cannot set away status.", wx.Colour(255, 0, 0)) else: self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 0, 0)) except Exception as e: