Add command autocomplete feature to IRCPanel

- Introduced CommandAutocomplete class for command suggestions.
- Enhanced IRCPanel to show command suggestions while typing.
- Improved error handling and user feedback for command execution.
- Updated message handling to support formatted text display.
This commit is contained in:
2025-12-06 17:21:41 +01:00
parent b4c74f098b
commit 35dfedd5c9
2 changed files with 449 additions and 49 deletions

View File

@@ -200,7 +200,179 @@ class KaomojiPicker(wx.PopupTransientWindow):
self.Popup() 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): class IRCPanel(wx.Panel):
# IRC commands with descriptions
IRC_COMMANDS = [
("help", "Show available commands"),
("join", "Join a channel - /join <channel>"),
("part", "Leave current or specified channel - /part [channel]"),
("msg", "Send private message - /msg <nick> <message>"),
("me", "Send action message - /me <action>"),
("nick", "Change nickname - /nick <newnick>"),
("whois", "Get user information - /whois <nick>"),
("topic", "Get or set channel topic - /topic [newtopic]"),
("kick", "Kick user from channel - /kick <user> [reason]"),
("away", "Set away status - /away [message]"),
("quit", "Disconnect from server - /quit [message]"),
]
# Default kaomoji groups # Default kaomoji groups
KAOMOJI_GROUPS = { KAOMOJI_GROUPS = {
"Happy": [ "Happy": [
@@ -245,6 +417,7 @@ class IRCPanel(wx.Panel):
self.history = [] self.history = []
self.history_pos = -1 self.history_pos = -1
self.current_popup = None self.current_popup = None
self.command_popup = None
# Search state # Search state
self.search_text = "" self.search_text = ""
@@ -262,10 +435,10 @@ class IRCPanel(wx.Panel):
"""Initialize the UI components.""" """Initialize the UI components."""
sizer = wx.BoxSizer(wx.VERTICAL) 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.text_ctrl = wx.TextCtrl(
self, 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.text_ctrl.SetFont(self._load_system_font())
self._apply_theme() self._apply_theme()
@@ -288,6 +461,7 @@ class IRCPanel(wx.Panel):
self.input_ctrl.SetHint("Type message here…") self.input_ctrl.SetHint("Type message here…")
self.input_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_send) 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_KEY_DOWN, self.on_key_down)
self.input_ctrl.Bind(wx.EVT_TEXT, self.on_input_text_change)
# Kaomoji button # Kaomoji button
random_kaomoji = self._get_random_kaomoji() random_kaomoji = self._get_random_kaomoji()
@@ -380,14 +554,15 @@ class IRCPanel(wx.Panel):
"""Thread-safe message addition.""" """Thread-safe message addition."""
try: try:
if wx.IsMainThread(): if wx.IsMainThread():
self._add_message_safe(message) self._add_message_safe(message, username_color, message_color, bold, italic, underline)
else: 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: except Exception as e:
logger.error(f"Error in add_message: {e}") logger.error(f"Error in add_message: {e}")
def _add_message_safe(self, message): def _add_message_safe(self, message, username_color=None, message_color=None,
"""Add message to display (must be called from main thread).""" bold=False, italic=False, underline=False):
"""Add message to display with formatting (must be called from main thread)."""
try: try:
self.messages.append(message) self.messages.append(message)
@@ -396,9 +571,29 @@ class IRCPanel(wx.Panel):
current_pos = self.text_ctrl.GetInsertionPoint() current_pos = self.text_ctrl.GetInsertionPoint()
at_bottom = (last_pos - current_pos) <= 10 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") 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 # Auto-scroll if at bottom
if at_bottom: if at_bottom:
self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition()) self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition())
@@ -409,20 +604,60 @@ class IRCPanel(wx.Panel):
def add_formatted_message(self, timestamp, username, content, def add_formatted_message(self, timestamp, username, content,
username_color=None, is_action=False): username_color=None, is_action=False):
"""Add a formatted IRC message.""" """Add a formatted IRC message with colored username."""
try: 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: if is_action:
message = f"{timestamp}* {username} {content}" self.text_ctrl.AppendText("* ")
else: else:
message = f"{timestamp}<{username}> {content}" self.text_ctrl.AppendText("<")
self.add_message(message)
# 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: except Exception as e:
logger.error(f"Error adding formatted message: {e}") logger.error(f"Error adding formatted message: {e}")
def add_system_message(self, message, color=None, bold=False): 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: try:
self.add_message(message) self.add_message(message, message_color=color, bold=bold)
except Exception as e: except Exception as e:
logger.error(f"Error adding system message: {e}") logger.error(f"Error adding system message: {e}")
@@ -467,6 +702,7 @@ class IRCPanel(wx.Panel):
def on_send(self, event): def on_send(self, event):
"""Send the current message.""" """Send the current message."""
try: try:
self._hide_command_popup() # Hide command popup when sending
message = self.input_ctrl.GetValue().strip() message = self.input_ctrl.GetValue().strip()
if message and self.target: if message and self.target:
self.history.append(message) self.history.append(message)
@@ -476,11 +712,110 @@ class IRCPanel(wx.Panel):
except Exception as e: except Exception as e:
logger.error(f"Error sending message: {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): def on_key_down(self, event):
"""Handle input field keyboard events.""" """Handle input field keyboard events."""
try: try:
keycode = event.GetKeyCode() 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: if keycode == wx.WXK_UP:
self._navigate_history_up() self._navigate_history_up()
elif keycode == wx.WXK_DOWN: elif keycode == wx.WXK_DOWN:
@@ -517,20 +852,47 @@ class IRCPanel(wx.Panel):
self.input_ctrl.Clear() self.input_ctrl.Clear()
def handle_tab_completion(self): def handle_tab_completion(self):
"""Handle nickname tab completion.""" """Handle tab completion for commands and nicknames."""
try: try:
current_text = self.input_ctrl.GetValue() 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 return
users = self.main_frame.channel_users.get(self.target, []) users = self.main_frame.channel_users.get(self.target, [])
if not users: if not users:
return return
pos = self.input_ctrl.GetInsertionPoint()
text_before = current_text[:pos]
words = text_before.split() words = text_before.split()
if not words: if not words:
return return

View File

@@ -1059,38 +1059,76 @@ Available commands:
user_color = self.get_user_color(self.nick) user_color = self.get_user_color(self.nick)
timestamp = self.get_timestamp() timestamp = self.get_timestamp()
self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True) self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True)
elif cmd == "nick" and self.is_connected(): elif cmd == "nick":
if self.is_connected():
self.connection.nick(args) self.connection.nick(args)
elif cmd == "join" and self.is_connected(): else:
if args and not args.startswith('#'): 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 <channel>", wx.Colour(255, 0, 0))
else:
if not args.startswith('#'):
args = '#' + args args = '#' + args
self.connection.join(args) self.connection.join(args)
elif cmd == "part" and self.is_connected(): 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 channel = args if args else target
self.connection.part(channel) self.connection.part(channel)
elif cmd == "quit" and self.is_connected(): 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" reason = args if args else "Goodbye"
self.connection.quit(reason) self.connection.quit(reason)
elif cmd == "msg" and self.is_connected(): 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) nick_msg = args.split(' ', 1)
if len(nick_msg) == 2: if len(nick_msg) == 2:
self.connection.privmsg(nick_msg[0], nick_msg[1]) self.connection.privmsg(nick_msg[0], nick_msg[1])
elif cmd == "whois" and self.is_connected(): else:
self.safe_ui_update(self.log_server, f"Usage: /msg <nick> <message>", 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]) self.connection.whois([args])
elif cmd == "kick" and self.is_connected(): else:
self.safe_ui_update(self.log_server, f"Usage: /whois <nick>", 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) kick_args = args.split(' ', 1)
user = kick_args[0] user = kick_args[0]
reason = kick_args[1] if len(kick_args) > 1 else "Kicked" reason = kick_args[1] if len(kick_args) > 1 else "Kicked"
self.connection.kick(target, user, reason) self.connection.kick(target, user, reason)
elif cmd == "topic" and self.is_connected(): else:
self.safe_ui_update(self.log_server, f"Usage: /kick <user> [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: if args:
self.connection.topic(target, args) self.connection.topic(target, args)
else: else:
self.connection.topic(target) self.connection.topic(target)
elif cmd == "away" and self.is_connected(): 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.connection.send_raw(f"AWAY :{args}" if args else "AWAY")
self.away = bool(args) self.away = bool(args)
self.safe_ui_update(self.away_item.Check, self.away) 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: else:
self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 0, 0)) 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: except Exception as e: