""" Text edit widget with autocomplete for mentions and emojis """ from PySide6.QtWidgets import QTextEdit, QCompleter, QListWidget, QListWidgetItem from PySide6.QtCore import Qt, Signal, QStringListModel, QRect from PySide6.QtGui import QTextCursor, QKeyEvent import re import emoji from typing import List, Dict class AutocompleteTextEdit(QTextEdit): """Text edit with @ mention and : emoji autocomplete""" mention_requested = Signal(str) # Emitted when user types @ to request user list emoji_requested = Signal(str) # Emitted when user types : to request emoji list def __init__(self, parent=None): super().__init__(parent) # Lists for autocomplete self.mention_list = [] # Will be populated from followers/following self.emoji_list = [] # Will be populated from instance custom emojis # Autocomplete state self.completer = None self.completion_prefix = "" self.completion_start = 0 self.completion_type = None # 'mention' or 'emoji' # Load comprehensive emoji dataset self.load_unicode_emojis() def load_unicode_emojis(self): """Load comprehensive Unicode emoji dataset using emoji library""" print("Loading Unicode emoji dataset...") self.emoji_list = [] # Get all emoji data from the emoji library emoji_data = emoji.EMOJI_DATA for emoji_char, data in emoji_data.items(): # Skip emojis without names if 'en' not in data: continue # Extract shortcode from emoji library format (removes colons) name = data['en'].strip() if name.startswith(':') and name.endswith(':'): shortcode = name[1:-1] # Remove surrounding colons else: shortcode = name.lower().replace(' ', '_').replace('-', '_') shortcode = re.sub(r'[^a-zA-Z0-9_]', '', shortcode) if not shortcode: continue # Create keywords from the shortcode # Split shortcode by underscores to get individual words words = shortcode.split('_') keywords = [] for word in words: if len(word) >= 2: # Only add words with 2+ chars keywords.append(word.lower()) # Add some common synonyms for frequent emojis synonyms = { 'grinning_face': ['smile', 'happy', 'grin'], 'face_with_tears_of_joy': ['laugh', 'lol', 'funny'], 'red_heart': ['love', 'heart'], 'thumbs_up': ['good', 'ok', 'yes', 'like'], 'thumbs_down': ['bad', 'no', 'dislike'], 'fire': ['hot', 'lit', 'flame'], 'star': ['favorite', 'best'], 'waving_hand': ['hello', 'hi', 'bye'], } if shortcode in synonyms: keywords.extend(synonyms[shortcode]) self.emoji_list.append({ "shortcode": shortcode, "emoji": emoji_char, "keywords": keywords }) # Loaded successfully def set_mention_list(self, mentions: List[str]): """Set the list of available mentions (usernames)""" self.mention_list = mentions def set_emoji_list(self, emojis: List[Dict]): """Set custom emoji list from instance""" # Combine with default emojis self.emoji_list.extend(emojis) def keyPressEvent(self, event: QKeyEvent): """Handle key press events for autocomplete""" key = event.key() # Handle autocomplete navigation if self.completer and self.completer.popup().isVisible(): if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]: self.handle_completer_key(key) return elif key == Qt.Key_Escape: self.hide_completer() return # Normal key processing super().keyPressEvent(event) # Check for autocomplete triggers self.check_autocomplete_trigger() def handle_completer_key(self, key): """Handle navigation keys in completer""" popup = self.completer.popup() if key in [Qt.Key_Up, Qt.Key_Down]: # Let the popup handle up/down if key == Qt.Key_Up: current = popup.currentIndex().row() if current > 0: popup.setCurrentIndex(popup.model().index(current - 1, 0)) else: current = popup.currentIndex().row() if current < popup.model().rowCount() - 1: popup.setCurrentIndex(popup.model().index(current + 1, 0)) elif key in [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]: # Insert the selected completion self.insert_completion() def check_autocomplete_trigger(self): """Check if we should show autocomplete""" cursor = self.textCursor() text = self.toPlainText() pos = cursor.position() # Find the current word being typed if pos == 0: return # Look backwards for @ or : start_pos = pos - 1 while start_pos >= 0 and start_pos < len(text) and text[start_pos] not in [' ', '\n', '\t']: start_pos -= 1 start_pos += 1 current_word = text[start_pos:pos] if current_word.startswith('@') and len(current_word) > 1: # Check if this is a completed mention (ends with space) if current_word.endswith(' '): self.hide_completer() return # Mention autocomplete prefix = current_word[1:] # Remove @ self.show_mention_completer(prefix, start_pos) elif current_word.startswith(':') and len(current_word) > 1: # Check if this is a completed emoji (ends with :) if current_word.endswith(':') and len(current_word) > 2: self.hide_completer() return # Emoji autocomplete - remove trailing : if present for matching prefix = current_word[1:] # Remove initial : if prefix.endswith(':'): prefix = prefix[:-1] # Remove trailing : for matching self.show_emoji_completer(prefix, start_pos) else: # Hide completer if not in autocomplete mode self.hide_completer() def show_mention_completer(self, prefix: str, start_pos: int): """Show mention autocomplete""" if not self.mention_list: # Request mention list from parent self.mention_requested.emit(prefix) return # Filter mentions matches = [name for name in self.mention_list if name.lower().startswith(prefix.lower())] if matches: self.show_completer(matches, prefix, start_pos, 'mention') else: self.hide_completer() def show_emoji_completer(self, prefix: str, start_pos: int): """Show emoji autocomplete""" # Filter emojis by shortcode and keywords matches = [] prefix_lower = prefix.lower() for emoji in self.emoji_list: shortcode = emoji['shortcode'] keywords = emoji.get('keywords', []) # Check if prefix matches shortcode or any keyword if (shortcode.startswith(prefix_lower) or any(keyword.startswith(prefix_lower) for keyword in keywords)): display_text = f"{shortcode} {emoji['emoji']}" matches.append(display_text) # Add to matches if matches: self.show_completer(matches, prefix, start_pos, 'emoji') else: self.hide_completer() def show_completer(self, items: List[str], prefix: str, start_pos: int, completion_type: str): """Show the completer with given items""" self.completion_prefix = prefix self.completion_start = start_pos self.completion_type = completion_type # Create or update completer if not self.completer: self.completer = QCompleter(self) self.completer.setWidget(self) self.completer.setCaseSensitivity(Qt.CaseInsensitive) # Set up model model = QStringListModel(items) self.completer.setModel(model) # Position the popup cursor = self.textCursor() cursor.setPosition(start_pos) rect = self.cursorRect(cursor) rect.setWidth(200) self.completer.complete(rect) # Set accessible name for screen readers popup = self.completer.popup() popup.setAccessibleName(f"{completion_type.title()} Autocomplete") def hide_completer(self): """Hide the completer""" if self.completer: popup = self.completer.popup() popup.hide() # Clear the completion state self.completion_prefix = "" self.completion_start = 0 self.completion_type = None def insert_completion(self): """Insert the selected completion""" if not self.completer: return popup = self.completer.popup() current_index = popup.currentIndex() if not current_index.isValid(): return # Get the actually selected completion from the popup completion = current_index.data() # Replace the current prefix with the completion cursor = self.textCursor() cursor.setPosition(self.completion_start) cursor.setPosition(cursor.position() + len(self.completion_prefix) + 1, QTextCursor.KeepAnchor) # +1 for @ or : if self.completion_type == 'mention': cursor.insertText(f"@{completion} ") elif self.completion_type == 'emoji': # Extract the actual emoji character (after the shortcode) parts = completion.split(' ', 1) if len(parts) >= 2: emoji_char = parts[1].strip() cursor.insertText(f"{emoji_char} ") else: # Fallback to shortcode format if parsing fails shortcode = parts[0] cursor.insertText(f":{shortcode}: ") # Set the updated cursor position self.setTextCursor(cursor) self.hide_completer() # Try immediate focus restoration self.setFocus(Qt.OtherFocusReason) # Also try with a delay as backup from PySide6.QtCore import QTimer QTimer.singleShot(0, self.restore_focus_immediate) QTimer.singleShot(100, self.restore_focus) def restore_focus_immediate(self): """Immediate focus restoration attempt""" self.setFocus(Qt.TabFocusReason) self.ensureCursorVisible() def restore_focus(self): """Delayed focus restoration as backup""" # Ensure we're visible and enabled before taking focus if self.isVisible() and self.isEnabled(): self.setFocus(Qt.TabFocusReason) # Use TabFocusReason instead self.activateWindow() # Also activate the parent window self.ensureCursorVisible() # Force a repaint to ensure visual focus self.update() def update_mention_list(self, mentions: List[str]): """Update mention list (called from parent when data is ready)""" self.mention_list = mentions # Don't re-trigger completer to avoid recursion # The completer will use the updated list on next keystroke