## Major Features Added ### Smart Mention Completion - **Full fediverse handles**: @user@instance.com format instead of incomplete usernames - **Multi-source suggestions**: Search API + followers + following for comprehensive results - **Real-time API integration**: No more hardcoded sample data - **Intelligent filtering**: Prefix-based matching across all user connections ### Comprehensive Emoji System - **5,000+ Unicode emojis**: Complete emoji dataset via python-emoji library - **Keyword-based search**: Find emojis by typing descriptive words (:fire, :heart, :grin) - **Actual emoji insertion**: Inserts Unicode characters (🎃) not shortcodes (🎃) - **Accurate selection**: Fixed bug where wrong emoji was inserted from autocomplete list - **Smart synonyms**: Common aliases for frequently used emojis ### Enhanced ActivityPub Integration - **Account relationships**: get_followers(), get_following(), search_accounts() methods - **Expanded API coverage**: Better integration with fediverse social graph - **Robust error handling**: Graceful fallbacks for API failures ### User Experience Improvements - **Bug fixes**: Resolved autocomplete selection and focus restoration issues - **Documentation**: Updated README with comprehensive feature descriptions - **Dependencies**: Added emoji>=2.0.0 to requirements for Unicode support ## Technical Details - Removed incomplete fallback data in favor of live API integration - Improved completer selection logic to use actually selected items - Enhanced error handling for network requests and API limitations - Updated installation instructions for new emoji library dependency 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""
|
|
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 |