Files
bifrost/src/widgets/autocomplete_textedit.py
Storm Dragon 6fa0cf481a Enhance compose experience with comprehensive autocomplete systems
## 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>
2025-07-20 16:06:04 -04:00

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