Initial commit: Bifrost accessible fediverse client
- Full ActivityPub support for Pleroma, GoToSocial, and Mastodon - Screen reader optimized interface with PySide6 - Timeline switching with tabs and keyboard shortcuts (Ctrl+1-4) - Threaded conversation navigation with expand/collapse - Cross-platform desktop notifications via plyer - Customizable sound pack system with audio feedback - Complete keyboard navigation and accessibility features - XDG Base Directory compliant configuration - Multiple account support with OAuth authentication 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
326
src/widgets/autocomplete_textedit.py
Normal file
326
src/widgets/autocomplete_textedit.py
Normal file
@ -0,0 +1,326 @@
|
||||
"""
|
||||
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
|
||||
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 default emojis
|
||||
self.load_default_emojis()
|
||||
|
||||
def load_default_emojis(self):
|
||||
"""Load a comprehensive set of Unicode emojis"""
|
||||
self.emoji_list = [
|
||||
# Faces
|
||||
{"shortcode": "smile", "emoji": "😄", "keywords": ["smile", "happy", "joy", "grin"]},
|
||||
{"shortcode": "laughing", "emoji": "😆", "keywords": ["laugh", "haha", "funny", "lol"]},
|
||||
{"shortcode": "wink", "emoji": "😉", "keywords": ["wink", "flirt", "hint"]},
|
||||
{"shortcode": "thinking", "emoji": "🤔", "keywords": ["thinking", "hmm", "consider", "ponder"]},
|
||||
{"shortcode": "shrug", "emoji": "🤷", "keywords": ["shrug", "dunno", "whatever", "idk"]},
|
||||
{"shortcode": "facepalm", "emoji": "🤦", "keywords": ["facepalm", "disappointed", "doh", "frustrated"]},
|
||||
{"shortcode": "crying", "emoji": "😭", "keywords": ["crying", "tears", "sad", "sob"]},
|
||||
{"shortcode": "angry", "emoji": "😠", "keywords": ["angry", "mad", "furious", "upset"]},
|
||||
{"shortcode": "cool", "emoji": "😎", "keywords": ["cool", "sunglasses", "awesome", "rad"]},
|
||||
{"shortcode": "joy", "emoji": "😂", "keywords": ["joy", "laugh", "tears", "funny"]},
|
||||
{"shortcode": "heart_eyes", "emoji": "😍", "keywords": ["heart", "eyes", "love", "crush"]},
|
||||
{"shortcode": "kiss", "emoji": "😘", "keywords": ["kiss", "love", "smooch", "mwah"]},
|
||||
{"shortcode": "tired", "emoji": "😴", "keywords": ["tired", "sleep", "sleepy", "zzz"]},
|
||||
{"shortcode": "shocked", "emoji": "😱", "keywords": ["shocked", "surprised", "scared", "omg"]},
|
||||
|
||||
# Hearts and symbols
|
||||
{"shortcode": "heart", "emoji": "❤️", "keywords": ["heart", "love", "red", "romance"]},
|
||||
{"shortcode": "blue_heart", "emoji": "💙", "keywords": ["blue", "heart", "love", "cold"]},
|
||||
{"shortcode": "green_heart", "emoji": "💚", "keywords": ["green", "heart", "love", "nature"]},
|
||||
{"shortcode": "yellow_heart", "emoji": "💛", "keywords": ["yellow", "heart", "love", "happy"]},
|
||||
{"shortcode": "purple_heart", "emoji": "💜", "keywords": ["purple", "heart", "love", "royal"]},
|
||||
{"shortcode": "black_heart", "emoji": "🖤", "keywords": ["black", "heart", "love", "dark"]},
|
||||
{"shortcode": "broken_heart", "emoji": "💔", "keywords": ["broken", "heart", "sad", "breakup"]},
|
||||
{"shortcode": "sparkling_heart", "emoji": "💖", "keywords": ["sparkling", "heart", "love", "sparkle"]},
|
||||
|
||||
# Gestures
|
||||
{"shortcode": "thumbsup", "emoji": "👍", "keywords": ["thumbs", "up", "good", "ok", "yes"]},
|
||||
{"shortcode": "thumbsdown", "emoji": "👎", "keywords": ["thumbs", "down", "bad", "no", "dislike"]},
|
||||
{"shortcode": "wave", "emoji": "👋", "keywords": ["wave", "hello", "hi", "goodbye", "bye"]},
|
||||
{"shortcode": "clap", "emoji": "👏", "keywords": ["clap", "applause", "bravo", "good"]},
|
||||
{"shortcode": "pray", "emoji": "🙏", "keywords": ["pray", "thanks", "please", "gratitude"]},
|
||||
{"shortcode": "ok_hand", "emoji": "👌", "keywords": ["ok", "hand", "perfect", "good"]},
|
||||
{"shortcode": "peace", "emoji": "✌️", "keywords": ["peace", "victory", "two", "fingers"]},
|
||||
{"shortcode": "crossed_fingers", "emoji": "🤞", "keywords": ["crossed", "fingers", "luck", "hope"]},
|
||||
|
||||
# Objects and symbols
|
||||
{"shortcode": "fire", "emoji": "🔥", "keywords": ["fire", "hot", "flame", "lit"]},
|
||||
{"shortcode": "star", "emoji": "⭐", "keywords": ["star", "favorite", "best", "top"]},
|
||||
{"shortcode": "rainbow", "emoji": "🌈", "keywords": ["rainbow", "colorful", "pride", "weather"]},
|
||||
{"shortcode": "lightning", "emoji": "⚡", "keywords": ["lightning", "bolt", "fast", "electric"]},
|
||||
{"shortcode": "snowflake", "emoji": "❄️", "keywords": ["snowflake", "cold", "winter", "frozen"]},
|
||||
{"shortcode": "sun", "emoji": "☀️", "keywords": ["sun", "sunny", "bright", "weather"]},
|
||||
{"shortcode": "moon", "emoji": "🌙", "keywords": ["moon", "night", "crescent", "sleep"]},
|
||||
{"shortcode": "cloud", "emoji": "☁️", "keywords": ["cloud", "weather", "sky", "cloudy"]},
|
||||
|
||||
# Food and drinks
|
||||
{"shortcode": "coffee", "emoji": "☕", "keywords": ["coffee", "drink", "morning", "caffeine"]},
|
||||
{"shortcode": "tea", "emoji": "🍵", "keywords": ["tea", "drink", "hot", "green"]},
|
||||
{"shortcode": "beer", "emoji": "🍺", "keywords": ["beer", "drink", "alcohol", "party"]},
|
||||
{"shortcode": "wine", "emoji": "🍷", "keywords": ["wine", "drink", "alcohol", "red"]},
|
||||
{"shortcode": "pizza", "emoji": "🍕", "keywords": ["pizza", "food", "italian", "slice"]},
|
||||
{"shortcode": "burger", "emoji": "🍔", "keywords": ["burger", "food", "meat", "american"]},
|
||||
{"shortcode": "cake", "emoji": "🎂", "keywords": ["cake", "birthday", "dessert", "sweet"]},
|
||||
{"shortcode": "cookie", "emoji": "🍪", "keywords": ["cookie", "dessert", "sweet", "snack"]},
|
||||
{"shortcode": "apple", "emoji": "🍎", "keywords": ["apple", "fruit", "red", "healthy"]},
|
||||
{"shortcode": "banana", "emoji": "🍌", "keywords": ["banana", "fruit", "yellow", "monkey"]},
|
||||
|
||||
# Animals
|
||||
{"shortcode": "cat", "emoji": "🐱", "keywords": ["cat", "kitten", "meow", "feline"]},
|
||||
{"shortcode": "dog", "emoji": "🐶", "keywords": ["dog", "puppy", "woof", "canine"]},
|
||||
{"shortcode": "mouse", "emoji": "🐭", "keywords": ["mouse", "small", "rodent", "squeak"]},
|
||||
{"shortcode": "bear", "emoji": "🐻", "keywords": ["bear", "large", "forest", "cute"]},
|
||||
{"shortcode": "panda", "emoji": "🐼", "keywords": ["panda", "bear", "black", "white"]},
|
||||
{"shortcode": "lion", "emoji": "🦁", "keywords": ["lion", "king", "mane", "roar"]},
|
||||
{"shortcode": "tiger", "emoji": "🐯", "keywords": ["tiger", "stripes", "orange", "wild"]},
|
||||
{"shortcode": "fox", "emoji": "🦊", "keywords": ["fox", "red", "clever", "sly"]},
|
||||
{"shortcode": "wolf", "emoji": "🐺", "keywords": ["wolf", "pack", "howl", "wild"]},
|
||||
{"shortcode": "unicorn", "emoji": "🦄", "keywords": ["unicorn", "magic", "rainbow", "fantasy"]},
|
||||
|
||||
# Activities and celebrations
|
||||
{"shortcode": "party", "emoji": "🎉", "keywords": ["party", "celebration", "confetti", "fun"]},
|
||||
{"shortcode": "birthday", "emoji": "🎂", "keywords": ["birthday", "cake", "celebration", "age"]},
|
||||
{"shortcode": "gift", "emoji": "🎁", "keywords": ["gift", "present", "box", "surprise"]},
|
||||
{"shortcode": "balloon", "emoji": "🎈", "keywords": ["balloon", "party", "float", "celebration"]},
|
||||
{"shortcode": "music", "emoji": "🎵", "keywords": ["music", "notes", "song", "melody"]},
|
||||
{"shortcode": "dance", "emoji": "💃", "keywords": ["dance", "woman", "party", "fun"]},
|
||||
|
||||
# Halloween and seasonal
|
||||
{"shortcode": "jack_o_lantern", "emoji": "🎃", "keywords": ["jack", "lantern", "pumpkin", "halloween"]},
|
||||
{"shortcode": "ghost", "emoji": "👻", "keywords": ["ghost", "spooky", "halloween", "boo"]},
|
||||
{"shortcode": "skull", "emoji": "💀", "keywords": ["skull", "death", "spooky", "halloween"]},
|
||||
{"shortcode": "spider", "emoji": "🕷️", "keywords": ["spider", "web", "spooky", "halloween"]},
|
||||
{"shortcode": "bat", "emoji": "🦇", "keywords": ["bat", "fly", "night", "halloween"]},
|
||||
{"shortcode": "christmas_tree", "emoji": "🎄", "keywords": ["christmas", "tree", "holiday", "winter"]},
|
||||
{"shortcode": "santa", "emoji": "🎅", "keywords": ["santa", "christmas", "holiday", "ho"]},
|
||||
{"shortcode": "snowman", "emoji": "⛄", "keywords": ["snowman", "winter", "cold", "carrot"]},
|
||||
|
||||
# Technology
|
||||
{"shortcode": "computer", "emoji": "💻", "keywords": ["computer", "laptop", "tech", "work"]},
|
||||
{"shortcode": "phone", "emoji": "📱", "keywords": ["phone", "mobile", "cell", "smartphone"]},
|
||||
{"shortcode": "camera", "emoji": "📷", "keywords": ["camera", "photo", "picture", "snap"]},
|
||||
{"shortcode": "video", "emoji": "📹", "keywords": ["video", "camera", "record", "film"]},
|
||||
{"shortcode": "robot", "emoji": "🤖", "keywords": ["robot", "ai", "artificial", "intelligence"]},
|
||||
|
||||
# Transportation
|
||||
{"shortcode": "car", "emoji": "🚗", "keywords": ["car", "drive", "vehicle", "auto"]},
|
||||
{"shortcode": "bike", "emoji": "🚲", "keywords": ["bike", "bicycle", "ride", "cycle"]},
|
||||
{"shortcode": "plane", "emoji": "✈️", "keywords": ["plane", "airplane", "fly", "travel"]},
|
||||
{"shortcode": "rocket", "emoji": "🚀", "keywords": ["rocket", "space", "launch", "fast"]},
|
||||
]
|
||||
|
||||
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 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)
|
||||
|
||||
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:
|
||||
self.completer.popup().hide()
|
||||
|
||||
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
|
||||
|
||||
completion = self.completer.currentCompletion()
|
||||
|
||||
# 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 just the shortcode (before the emoji)
|
||||
shortcode = completion.split()[0]
|
||||
cursor.insertText(f":{shortcode}: ")
|
||||
|
||||
self.hide_completer()
|
||||
|
||||
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
|
Reference in New Issue
Block a user