374 lines
15 KiB
Python
374 lines
15 KiB
Python
import wx
|
|
import threading
|
|
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 IRCPanel(wx.Panel):
|
|
def __init__(self, parent, main_frame):
|
|
super().__init__(parent)
|
|
self.parent = parent
|
|
self.main_frame = main_frame
|
|
self.messages = []
|
|
|
|
sizer = wx.BoxSizer(wx.VERTICAL)
|
|
|
|
# Use a better font for chat with white theme
|
|
self.text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.TE_AUTO_URL)
|
|
|
|
# White theme colors
|
|
self.text_ctrl.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background
|
|
self.text_ctrl.SetForegroundColour(wx.Colour(0, 0, 0)) # Black text
|
|
|
|
# Load appropriate font
|
|
self.font = self.load_system_font()
|
|
self.text_ctrl.SetFont(self.font)
|
|
|
|
sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0)
|
|
|
|
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
|
self.input_ctrl = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER)
|
|
self.input_ctrl.SetHint("Type message here …")
|
|
self.input_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_send)
|
|
self.input_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_key_down)
|
|
|
|
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(send_btn, 0, wx.ALL, 2)
|
|
|
|
sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0)
|
|
|
|
self.SetSizer(sizer)
|
|
self.target = None
|
|
self.history = []
|
|
self.history_pos = -1
|
|
|
|
# Search state
|
|
self.search_text = ""
|
|
self.search_positions = []
|
|
self.current_search_index = -1
|
|
|
|
# Bind Ctrl+F for search
|
|
self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down)
|
|
accel_tbl = wx.AcceleratorTable([(wx.ACCEL_CTRL, ord('F'), wx.ID_FIND)])
|
|
self.SetAcceleratorTable(accel_tbl)
|
|
self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND)
|
|
|
|
def load_system_font(self):
|
|
"""Load appropriate system font with high DPI support"""
|
|
try:
|
|
# Get system DPI scale factor
|
|
dc = wx.ClientDC(self)
|
|
dpi_scale = dc.GetPPI().GetWidth() / 96.0 # 96 is standard DPI
|
|
|
|
# Calculate base font size based on DPI
|
|
base_size = 10
|
|
if dpi_scale > 1.5:
|
|
font_size = int(base_size * 1.5) # 150% scaling
|
|
elif dpi_scale > 1.25:
|
|
font_size = int(base_size * 1.25) # 125% scaling
|
|
else:
|
|
font_size = base_size
|
|
|
|
# Try system fonts in order of preference
|
|
font_families = [
|
|
(wx.FONTFAMILY_TELETYPE, "Consolas"),
|
|
(wx.FONTFAMILY_TELETYPE, "Courier New"),
|
|
(wx.FONTFAMILY_TELETYPE, "Monaco"),
|
|
(wx.FONTFAMILY_TELETYPE, "DejaVu Sans Mono"),
|
|
(wx.FONTFAMILY_TELETYPE, "Liberation Mono"),
|
|
]
|
|
|
|
for family, face_name in font_families:
|
|
font = wx.Font(font_size, family, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, face_name)
|
|
if font.IsOk():
|
|
logger.info(f"Using font: {face_name} at {font_size}pt")
|
|
return font
|
|
|
|
# Fallback to default monospace
|
|
font = wx.Font(font_size, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
|
logger.info("Using system monospace font as fallback")
|
|
return font
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading system font: {e}")
|
|
# Ultimate fallback
|
|
return wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
|
|
|
|
def set_target(self, 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 with username coloring"""
|
|
try:
|
|
# Use CallAfter for thread safety
|
|
if wx.IsMainThread():
|
|
self._add_message_safe(message, username, username_color, message_color, bold, italic, underline)
|
|
else:
|
|
wx.CallAfter(self._add_message_safe, message, username, 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=None, username_color=None, message_color=None, bold=False, italic=False, underline=False):
|
|
"""Actually add message - must be called from main thread"""
|
|
try:
|
|
self.messages.append(message)
|
|
|
|
# Save current position for formatting
|
|
start_pos = self.text_ctrl.GetLastPosition()
|
|
|
|
if username and username_color:
|
|
# Add username with its color
|
|
attr = wx.TextAttr()
|
|
attr.SetTextColour(username_color)
|
|
if bold:
|
|
attr.SetFontWeight(wx.FONTWEIGHT_BOLD)
|
|
if italic:
|
|
attr.SetFontStyle(wx.FONTSTYLE_ITALIC)
|
|
if underline:
|
|
attr.SetFontUnderlined(True)
|
|
attr.SetFont(self.font)
|
|
self.text_ctrl.SetDefaultStyle(attr)
|
|
self.text_ctrl.AppendText(username)
|
|
|
|
# Add the rest of the message with message color
|
|
attr = wx.TextAttr()
|
|
if message_color:
|
|
attr.SetTextColour(message_color)
|
|
else:
|
|
attr.SetTextColour(wx.Colour(0, 0, 0)) # Black text for white theme
|
|
|
|
attr.SetFont(self.font)
|
|
self.text_ctrl.SetDefaultStyle(attr)
|
|
|
|
# Append the message (without username if we already added it)
|
|
if username and username_color:
|
|
# Find the message part after username
|
|
message_text = message[message.find(username) + len(username):]
|
|
self.text_ctrl.AppendText(message_text + "\n")
|
|
else:
|
|
self.text_ctrl.AppendText(message + "\n")
|
|
|
|
# Auto-scroll to bottom
|
|
self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition())
|
|
except Exception as e:
|
|
logger.error(f"Error adding message safely: {e}")
|
|
|
|
def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False):
|
|
"""Add a formatted message with colored username"""
|
|
try:
|
|
if is_action:
|
|
message = f"{timestamp}* {username} {content}"
|
|
self.add_message(message, f"* {username}", username_color, wx.Colour(128, 0, 128), italic=True) # Dark purple for actions
|
|
else:
|
|
message = f"{timestamp}<{username}> {content}"
|
|
self.add_message(message, f"<{username}>", username_color, wx.Colour(0, 0, 0)) # Black text
|
|
except Exception as e:
|
|
logger.error(f"Error adding formatted message: {e}")
|
|
|
|
def add_system_message(self, message, color=None, bold=False):
|
|
"""Add system message without username coloring"""
|
|
try:
|
|
if color is None:
|
|
color = wx.Colour(0, 0, 128) # Dark blue for system messages
|
|
self.add_message(message, None, None, color, bold, False, False)
|
|
except Exception as e:
|
|
logger.error(f"Error adding system message: {e}")
|
|
|
|
def on_text_key_down(self, event):
|
|
"""Handle key events in the text control"""
|
|
keycode = event.GetKeyCode()
|
|
if event.ControlDown() and keycode == ord('F'):
|
|
self.on_search(event)
|
|
else:
|
|
event.Skip()
|
|
|
|
def on_search(self, event):
|
|
"""Open search dialog"""
|
|
try:
|
|
dlg = SearchDialog(self)
|
|
dlg.ShowModal()
|
|
dlg.Destroy()
|
|
except Exception as e:
|
|
logger.error(f"Error in search: {e}")
|
|
|
|
def perform_search(self, search_text, case_sensitive=False, whole_word=False):
|
|
"""Perform text search in the chat history"""
|
|
try:
|
|
self.search_text = search_text
|
|
self.search_positions = []
|
|
self.current_search_index = -1
|
|
|
|
# Get all text
|
|
full_text = self.text_ctrl.GetValue()
|
|
if not full_text or not search_text:
|
|
return
|
|
|
|
# Prepare search parameters
|
|
search_flags = 0
|
|
if not case_sensitive:
|
|
# For manual search, we'll handle case sensitivity ourselves
|
|
search_text_lower = search_text.lower()
|
|
full_text_lower = full_text.lower()
|
|
|
|
# Manual search implementation since wx.TextCtrl doesn't have FindText
|
|
pos = 0
|
|
while pos < len(full_text):
|
|
if case_sensitive:
|
|
# Case sensitive search
|
|
found_pos = full_text.find(search_text, pos)
|
|
else:
|
|
# Case insensitive search
|
|
found_pos = full_text_lower.find(search_text_lower, pos)
|
|
|
|
if found_pos == -1:
|
|
break
|
|
|
|
# For whole word search, verify boundaries
|
|
if whole_word:
|
|
# Check if it's a whole word
|
|
is_word_start = (found_pos == 0 or not full_text[found_pos-1].isalnum())
|
|
is_word_end = (found_pos + len(search_text) >= len(full_text) or
|
|
not full_text[found_pos + len(search_text)].isalnum())
|
|
|
|
if is_word_start and is_word_end:
|
|
self.search_positions.append(found_pos)
|
|
pos = found_pos + 1 # Move forward to avoid infinite loop
|
|
else:
|
|
self.search_positions.append(found_pos)
|
|
pos = found_pos + len(search_text)
|
|
|
|
if self.search_positions:
|
|
self.current_search_index = 0
|
|
self.highlight_search_result()
|
|
self.main_frame.SetStatusText(f"Found {len(self.search_positions)} occurrences of '{search_text}'")
|
|
else:
|
|
self.main_frame.SetStatusText(f"Text '{search_text}' not found")
|
|
wx.Bell()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error performing search: {e}")
|
|
traceback.print_exc()
|
|
|
|
def highlight_search_result(self):
|
|
"""Highlight the current search result"""
|
|
try:
|
|
if not self.search_positions or self.current_search_index < 0:
|
|
return
|
|
|
|
pos = self.search_positions[self.current_search_index]
|
|
|
|
# Select the found text
|
|
self.text_ctrl.SetSelection(pos, pos + len(self.search_text))
|
|
self.text_ctrl.ShowPosition(pos)
|
|
|
|
# Update status
|
|
self.main_frame.SetStatusText(
|
|
f"Found {self.current_search_index + 1} of {len(self.search_positions)}: '{self.search_text}'"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error highlighting search result: {e}")
|
|
|
|
def find_next(self):
|
|
"""Find next occurrence"""
|
|
if self.search_positions:
|
|
self.current_search_index = (self.current_search_index + 1) % len(self.search_positions)
|
|
self.highlight_search_result()
|
|
|
|
def find_previous(self):
|
|
"""Find previous occurrence"""
|
|
if self.search_positions:
|
|
self.current_search_index = (self.current_search_index - 1) % len(self.search_positions)
|
|
self.highlight_search_result()
|
|
|
|
def on_key_down(self, event):
|
|
try:
|
|
keycode = event.GetKeyCode()
|
|
if keycode == wx.WXK_UP:
|
|
if self.history and self.history_pos < len(self.history) - 1:
|
|
self.history_pos += 1
|
|
self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)])
|
|
elif keycode == wx.WXK_DOWN:
|
|
if self.history_pos > 0:
|
|
self.history_pos -= 1
|
|
self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)])
|
|
elif self.history_pos == 0:
|
|
self.history_pos = -1
|
|
self.input_ctrl.Clear()
|
|
elif keycode == wx.WXK_TAB:
|
|
# Tab completion for nicknames
|
|
self.handle_tab_completion()
|
|
return # Don't skip to prevent default tab behavior
|
|
elif keycode == wx.WXK_F3:
|
|
# F3 for find next
|
|
if self.search_positions:
|
|
self.find_next()
|
|
return
|
|
elif event.ShiftDown() and keycode == wx.WXK_F3:
|
|
# Shift+F3 for find previous
|
|
if self.search_positions:
|
|
self.find_previous()
|
|
return
|
|
else:
|
|
event.Skip()
|
|
except Exception as e:
|
|
logger.error(f"Error in key handler: {e}")
|
|
event.Skip()
|
|
|
|
def handle_tab_completion(self):
|
|
"""Handle tab completion for nicknames"""
|
|
try:
|
|
current_text = self.input_ctrl.GetValue()
|
|
if not current_text or not self.target or not self.target.startswith('#'):
|
|
return
|
|
|
|
users = self.main_frame.channel_users.get(self.target, [])
|
|
if not users:
|
|
return
|
|
|
|
# Find word at cursor position
|
|
pos = self.input_ctrl.GetInsertionPoint()
|
|
text_before = current_text[:pos]
|
|
words = text_before.split()
|
|
|
|
if not words:
|
|
return
|
|
|
|
current_word = words[-1]
|
|
|
|
# Find matching nicks
|
|
matches = [user for user in users if user.lower().startswith(current_word.lower())]
|
|
|
|
if matches:
|
|
if len(matches) == 1:
|
|
# Single match - complete it
|
|
new_word = matches[0]
|
|
if ':' in text_before or text_before.strip().endswith(current_word):
|
|
# Replace the current word
|
|
new_text = text_before[:-len(current_word)] + new_word + current_text[pos:]
|
|
self.input_ctrl.SetValue(new_text)
|
|
self.input_ctrl.SetInsertionPoint(pos - len(current_word) + len(new_word))
|
|
else:
|
|
# Multiple matches - show in status
|
|
self.main_frame.SetStatusText(f"Tab completion: {', '.join(matches[:5])}{'...' if len(matches) > 5 else ''}")
|
|
except Exception as e:
|
|
logger.error(f"Error in tab completion: {e}")
|
|
|
|
def on_send(self, event):
|
|
try:
|
|
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}")
|