1030 lines
39 KiB
Python
1030 lines
39 KiB
Python
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}") |