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:
Storm Dragon
2025-07-20 03:39:47 -04:00
commit 460dfc52a5
31 changed files with 5320 additions and 0 deletions

View 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