568 lines
24 KiB
Python
568 lines
24 KiB
Python
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}")
|