Files
ircdvd/src/IRCPanel.py
2025-12-14 18:35:51 +01:00

1030 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <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
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}")