Files
ircdvd/src/IRCPanel.py

568 lines
24 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 , wx.adv
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 = []
self.theme = getattr(self.main_frame, "theme", None)
self.default_text_colour = self.theme["text"] if self.theme else wx.Colour(0, 0, 0)
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)
if self.theme:
self.text_ctrl.SetBackgroundColour(self.theme["content_bg"])
self.text_ctrl.SetForegroundColour(self.theme["text"])
self.SetBackgroundColour(self.theme["content_bg"])
# 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)
# Kaomoji picker button - inserts plain ASCII kaomojis into the input box
self.kaomoji_btn = wx.Button(self, label="Emotes")
self.kaomoji_btn.SetToolTip("Emotes :3")
self.kaomoji_btn.Bind(wx.EVT_BUTTON, self.on_pick_kaomoji)
send_btn = wx.Button(self, label="Send")
send_btn.SetToolTip("Send message (Enter)")
send_btn.Bind(wx.EVT_BUTTON, self.on_send)
# Order: input field, kaomoji, 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)
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(self.default_text_colour)
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, self.default_text_colour)
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):
"""Send the current input to the active IRC target."""
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}")
def insert_text_at_caret(self, text):
"""Insert given text at the current caret position in the input box."""
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 at caret: {e}")
def on_pick_kaomoji(self, event):
"""
Show a grouped kaomoji popup next to the button and insert the chosen one.
"""
try:
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 / Misc": [
":-O", ":O", ":-0", "O_O", "o_O", "O_o"
],
"Sleepy | Bored": [
"-_-", "(-.-) zzz", "(~_~)", "zzz"
],
"Gay": [
r"¯\_(._.)_/¯", "(¬_¬)", "(*_*)", "(>_>)", "(<_<)", "(o_O)", "(O_o)", "('_')",
"(//▽//)", "(*^///^*)", ">///<", "^_^;", "^///^;", "owo", "uwu", "rawr", ":33p"
],
"Blush": [
"^///^", "(//▽//)", "(*^///^*)", ">///<", "^_^;", "^///^;",
"(*^▽^*)", "(*´▽`*)"
],
"Others": [
"UwU~", "OwO~", ":33", "x3", ":3~", ":3c", "rawr x3", "xD", ";-)", ";)", ":-P", ":P", ":-|", ":|"
]
}
display_items = []
item_lookup = [] # tuple: (group, index in group) or None for separators/labels
# Helper for ASCII filtering
def is_ascii(s):
return all(ord(ch) < 128 for ch in s)
for group, choices in kaomoji_groups.items():
group_ascii_choices = [c for c in choices if is_ascii(c)]
if not group_ascii_choices:
continue
display_items.append(f"──────────── {group} ────────────")
item_lookup.append(None) # None means not selectable
for idx, choice in enumerate(group_ascii_choices):
display_items.append(f" {choice}")
item_lookup.append((group, idx))
popup = wx.PopupTransientWindow(self, wx.BORDER_SIMPLE)
panel = wx.Panel(popup)
sizer = wx.BoxSizer(wx.VERTICAL)
listbox = wx.ListBox(
panel, choices=display_items, style=wx.LB_SINGLE
)
sizer.Add(listbox, 1, wx.EXPAND | wx.ALL, 4)
panel.SetSizerAndFit(sizer)
popup.SetClientSize(panel.GetBestSize())
# Keep a reference so the popup isn't GC'd
self._kaomoji_popup = popup
def get_kaomoji_by_index(list_idx):
"""Given listbox index, return the chosen kaomoji str or None."""
lookup = item_lookup[list_idx]
if not lookup:
return None
group, group_idx = lookup
if group:
choices = [c for c in kaomoji_groups[group] if is_ascii(c)]
return choices[group_idx]
else:
# Fallback: old style flat
return display_items[list_idx].strip()
def is_selectable(list_idx):
"""Whether this list index is a real choice (not a separator/label)."""
return item_lookup[list_idx] is not None
def on_select(evt):
"""Handle selection from the kaomoji list (keyboard or programmatic)."""
# Ignore synthetic selection changes triggered only for hover visualization
if getattr(self, "_suppress_kaomoji_select", False):
return
try:
idx = evt.GetSelection()
except AttributeError:
# Fallback for synthetic events where we used SetInt()
idx = evt.GetInt() if hasattr(evt, "GetInt") else -1
try:
if idx is not None and is_selectable(idx):
choice = get_kaomoji_by_index(idx)
if choice:
current = self.input_ctrl.GetValue()
pos = self.input_ctrl.GetInsertionPoint()
needs_space = pos > 0 and not current[pos - 1].isspace()
insert_text = (" " + choice) if needs_space else choice
new_value = current[:pos] + insert_text + current[pos:]
self.input_ctrl.SetValue(new_value)
self.input_ctrl.SetInsertionPoint(pos + len(insert_text))
finally:
popup.Dismiss()
def on_left_click(evt):
"""Single left-click handler for the kaomoji menu."""
try:
pos = evt.GetPosition()
idx = listbox.HitTest(pos)
if idx != wx.NOT_FOUND and is_selectable(idx):
# Ensure the item is selected, then reuse on_select logic
listbox.SetSelection(idx)
cmd_evt = wx.CommandEvent(wx.wxEVT_LISTBOX, listbox.GetId())
cmd_evt.SetEventObject(listbox)
cmd_evt.SetInt(idx)
on_select(cmd_evt)
else:
evt.Skip()
except Exception as e:
logger.error(f"Error in kaomoji left-click handler: {e}")
def on_motion(evt):
"""Visual hover selector so the current row is highlighted."""
try:
pos = evt.GetPosition()
idx = listbox.HitTest(pos)
current_sel = listbox.GetSelection()
if idx != wx.NOT_FOUND and is_selectable(idx) and idx != current_sel:
# Temporarily suppress on_select so hover highlight doesn't send
self._suppress_kaomoji_select = True
try:
listbox.SetSelection(idx)
finally:
self._suppress_kaomoji_select = False
elif idx == wx.NOT_FOUND or not is_selectable(idx):
# Optionally clear selection when hovering outside items or on label row
self._suppress_kaomoji_select = True
try:
# wx.ListBox does not have DeselectAll. Use SetSelection(-1) to clear selection.
listbox.SetSelection(wx.NOT_FOUND)
finally:
self._suppress_kaomoji_select = False
except Exception as e:
logger.error(f"Error in kaomoji hover handler: {e}")
finally:
evt.Skip()
# Single left-click selects and sends; keyboard selection still works
listbox.Bind(wx.EVT_LISTBOX, on_select)
listbox.Bind(wx.EVT_LEFT_DOWN, on_left_click)
listbox.Bind(wx.EVT_MOTION, on_motion)
def on_draw_item(event):
pass
# Position popup under the kaomoji button
btn = self.kaomoji_btn
btn_pos = btn.ClientToScreen((0, btn.GetSize().height))
popup.Position(btn_pos, (0, 0))
popup.Popup()
except Exception as e:
logger.error(f"Error in kaomoji picker: {e}")